extensibility: rules engine (#511)

* rules format prototype

* config container

* lightweight rules

* fdescribe

* basic rule integration

* migrate "create folder" to click actions

* migrate toolbar to new action handlers

* rule support for "create folder" (toolbar)

* upgrade "View" toolbar command

* migrate to rules

* cleanup tests
This commit is contained in:
Denys Vuika
2018-07-16 11:27:27 +01:00
committed by Cilibiu Bogdan
parent d5763f585d
commit 51af2071c2
17 changed files with 441 additions and 247 deletions

View File

@@ -42,6 +42,28 @@
"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",
@@ -81,11 +103,6 @@
"type": "SNACKBAR_INFO",
"payload": "I'm a nice little popup raised by extension."
},
{
"id": "aca:actions/error",
"type": "SNACKBAR_ERROR",
"payload": "Aw, Snap!"
},
{
"id": "aca:actions/node-name",
"type": "SNACKBAR_INFO",
@@ -100,14 +117,14 @@
"features": {
"create": [
{
"disabled": false,
"id": "aca:create/folder",
"order": 100,
"id": "app.create.folder",
"icon": "create_new_folder",
"title": "ext: Create Folder",
"target": {
"permissions": ["create"],
"action": "aca:actions/create-folder"
"actions": {
"click": "aca:actions/create-folder"
},
"rules": {
"enabled": "app.create.canCreateFolder"
}
}
],
@@ -210,10 +227,11 @@
"order": 10,
"title": "APP.NEW_MENU.TOOLTIPS.CREATE_FOLDER",
"icon": "create_new_folder",
"target": {
"types": [],
"permissions": ["parent.create"],
"action": "aca:actions/create-folder"
"actions": {
"click": "aca:actions/create-folder"
},
"rules": {
"visible": "app.create.canCreateFolder"
}
},
{
@@ -222,10 +240,11 @@
"order": 15,
"title": "APP.ACTIONS.VIEW",
"icon": "open_in_browser",
"target": {
"types": ["file"],
"permissions": [],
"action": "aca:actions/preview"
"actions": {
"click": "aca:actions/preview"
},
"rules": {
"visible": "app.toolbar.canViewFile"
}
},
{
@@ -234,11 +253,11 @@
"order": 20,
"title": "APP.ACTIONS.DOWNLOAD",
"icon": "get_app",
"target": {
"types": ["file", "folder"],
"permissions": [],
"action": "aca:actions/download",
"multiple": true
"actions": {
"click": "aca:actions/download"
},
"rules": {
"visible": "app.toolbar.canDownload"
}
},
{
@@ -247,10 +266,11 @@
"order": 30,
"title": "APP.ACTIONS.EDIT",
"icon": "create",
"target": {
"types": ["folder"],
"permissions": ["update"],
"action": "aca:actions/edit-folder"
"actions": {
"click": "aca:actions/edit-folder"
},
"rules": {
"visible": "app.toolbar.canEditFolder"
}
},
@@ -270,21 +290,8 @@
"type": "button",
"title": "Settings",
"icon": "settings_applications",
"target": {
"types": [],
"permissions": [],
"action": "aca:actions/settings"
}
},
{
"id": "aca:action4",
"type": "button",
"title": "Error",
"icon": "report_problem",
"target": {
"types": ["file"],
"permissions": ["update", "delete"],
"action": "aca:actions/error"
"actions": {
"click": "aca:actions/settings"
}
}
]

View File

@@ -85,6 +85,7 @@ import { SearchResultsRowComponent } from './components/search/search-results-ro
import { NodePermissionsDialogComponent } from './dialogs/node-permissions/node-permissions.dialog';
import { NodePermissionsDirective } from './common/directives/node-permissions.directive';
import { PermissionsManagerComponent } from './components/permission-manager/permissions-manager.component';
import { RuleService } from './extensions/rules/rule.service';
@NgModule({
imports: [
@@ -161,7 +162,8 @@ import { PermissionsManagerComponent } from './components/permission-manager/per
ProfileResolver,
ExperimentalGuard,
ContentApiService,
ExtensionService
ExtensionService,
RuleService
],
entryComponents: [
LibraryDialogComponent,

View File

@@ -83,8 +83,7 @@ export abstract class PageComponent implements OnInit, OnDestroy {
if (selection.isEmpty) {
this.infoDrawerOpened = false;
}
const selectedNodes = selection ? selection.nodes : null;
this.actions = this.extensions.getAllowedContentActions(selectedNodes, this.node);
this.actions = this.extensions.getAllowedContentActions();
this.canUpdateFile = this.selection.file && this.content.canUpdateNode(selection.file);
this.canUpdateNode = this.selection.count === 1 && this.content.canUpdateNode(selection.first);
this.canDelete = !this.selection.isEmpty && this.content.canDeleteNodes(selection.nodes);

View File

@@ -10,7 +10,7 @@
<button *ngFor="let entry of createActions"
mat-menu-item
[disabled]="entry.disabled"
(click)="runAction(entry.target.action)">
(click)="runAction(entry.actions.click)">
<mat-icon>{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>

View File

@@ -3,7 +3,7 @@
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
(click)="runAction(entry.actions.click)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider *ngSwitchCase="'separator'"></adf-toolbar-divider>
@@ -20,7 +20,7 @@
[overlapTrigger]="false">
<ng-container *ngFor="let child of entry.children">
<button mat-menu-item
(click)="runAction(child.target.action)">
(click)="runAction(child.actions.click)">
<mat-icon>{{ child.icon }}</mat-icon>
<span>{{ child.title | translate }}</span>
</button>

View File

@@ -38,10 +38,13 @@ export interface ContentActionExtension {
icon?: string;
disabled?: boolean;
children?: Array<ContentActionExtension>;
target: {
types: Array<string>;
permissions: Array<string>,
action: string;
multiple?: boolean;
actions?: {
click?: string;
[key: string]: string;
};
rules: {
enabled?: string;
visible?: string;
[key: string]: string;
};
}

View File

@@ -0,0 +1,35 @@
/*!
* @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 { RouteExtension } from './route.extension';
import { ActionExtension } from './action.extension';
import { RuleRef } from './rules/rule-ref';
export interface ExtensionConfig {
rules?: Array<RuleRef>;
routes?: Array<RouteExtension>;
actions?: Array<ActionExtension>;
features?: { [key: string]: any };
}

View File

@@ -491,63 +491,6 @@ describe('ExtensionService', () => {
});
});
describe('permissions', () => {
it('should approve node permission', () => {
const node: any = {
allowableOperations: ['create']
};
expect(extensions.nodeHasPermission(node, 'create')).toBeTruthy();
});
it('should not approve node permission', () => {
const node: any = {
allowableOperations: ['create']
};
expect(extensions.nodeHasPermission(node, 'update')).toBeFalsy();
});
it('should not approve node permission when node missing property', () => {
const node: any = {
allowableOperations: null
};
expect(extensions.nodeHasPermission(node, 'update')).toBeFalsy();
});
it('should require node to check permission', () => {
expect(extensions.nodeHasPermission(null, 'create')).toBeFalsy();
});
it('should require permission value to check', () => {
const node: any = {
allowableOperations: ['create']
};
expect(extensions.nodeHasPermission(node, null)).toBeFalsy();
});
it('should approve multiple permissions', () => {
const node: any = {
allowableOperations: ['create', 'update', 'delete']
};
expect(
extensions.nodeHasPermissions(node, ['create', 'delete'])
).toBeTruthy();
});
it('should require node to check multiple permissions', () => {
expect(extensions.nodeHasPermissions(null, ['create'])).toBeFalsy();
});
it('should require multiple permissions to check', () => {
const node: any = {
allowableOperations: ['create', 'update', 'delete']
};
expect(extensions.nodeHasPermissions(node, null)).toBeFalsy();
});
});
describe('sorting', () => {
it('should sort by provided order', () => {
const sorted = [

View File

@@ -36,7 +36,8 @@ import { AppStore } from '../store/states';
import { Store } from '@ngrx/store';
import { NavigationExtension } from './navigation.extension';
import { Route } from '@angular/router';
import { Node, MinimalNodeEntity } from 'alfresco-js-api';
import { Node } from 'alfresco-js-api';
import { RuleService } from './rules/rule.service';
@Injectable()
export class ExtensionService {
@@ -52,7 +53,8 @@ export class ExtensionService {
constructor(
private config: AppConfigService,
private store: Store<AppStore>
private store: Store<AppStore>,
private ruleService: RuleService
) {}
// initialise extension service
@@ -89,6 +91,8 @@ export class ExtensionService {
[]
)
.sort(this.sortByOrder);
this.ruleService.init();
}
getRouteById(id: string): RouteExtension {
@@ -183,38 +187,28 @@ export class ExtensionService {
// evaluates create actions for the folder node
getFolderCreateActions(folder: Node): Array<ContentActionExtension> {
return this.createActions.filter(this.filterEnabled).map(action => {
if (
action.target &&
action.target.permissions &&
action.target.permissions.length > 0
) {
return this.createActions
.filter(this.filterEnabled)
.filter(action => this.filterByRules(action))
.map(action => {
let disabled = false;
if (action.rules && action.rules.enabled) {
disabled = !this.ruleService.evaluateRule(action.rules.enabled);
}
return {
...action,
disabled: !this.nodeHasPermissions(
folder,
action.target.permissions
),
target: {
...action.target
}
disabled
};
}
return action;
});
}
// evaluates content actions for the selection and parent folder node
getAllowedContentActions(
nodes: MinimalNodeEntity[],
parentNode: Node
): Array<ContentActionExtension> {
getAllowedContentActions(): Array<ContentActionExtension> {
return this.contentActions
.filter(this.filterEnabled)
.filter(action => this.filterByTarget(nodes, action))
.filter(action =>
this.filterByPermission(nodes, action, parentNode)
)
.filter(action => this.filterByRules(action))
.reduce(this.reduceSeparators, [])
.map(action => {
if (action.type === ContentActionType.menu) {
@@ -222,14 +216,7 @@ export class ExtensionService {
if (copy.children && copy.children.length > 0) {
copy.children = copy.children
.filter(childAction =>
this.filterByTarget(nodes, childAction)
)
.filter(childAction =>
this.filterByPermission(
nodes,
childAction,
parentNode
)
this.filterByRules(childAction)
)
.reduce(this.reduceSeparators, []);
}
@@ -301,108 +288,10 @@ export class ExtensionService {
};
}
filterByTarget(
nodes: MinimalNodeEntity[],
action: ContentActionExtension
): boolean {
if (!action) {
return false;
filterByRules(action: ContentActionExtension): boolean {
if (action && action.rules && action.rules.visible) {
return this.ruleService.evaluateRule(action.rules.visible);
}
if (!action.target) {
return (
action.type === ContentActionType.separator ||
action.type === ContentActionType.menu
);
}
const types = action.target.types || [];
if (types.length === 0) {
return true;
}
if (nodes && nodes.length > 0) {
return types.some(type => {
if (type === 'folder') {
return action.target.multiple
? nodes.some(node => node.entry.isFolder)
: nodes.length === 1 &&
nodes.every(node => node.entry.isFolder);
}
if (type === 'file') {
return action.target.multiple
? nodes.some(node => node.entry.isFile)
: nodes.length === 1 &&
nodes.every(node => node.entry.isFile);
}
return false;
});
}
return false;
}
filterByPermission(
nodes: MinimalNodeEntity[],
action: ContentActionExtension,
parentNode: Node
): boolean {
if (!action) {
return false;
}
if (!action.target) {
return (
action.type === ContentActionType.separator ||
action.type === ContentActionType.menu
);
}
const permissions = action.target.permissions || [];
if (permissions.length === 0) {
return true;
}
return permissions.some(permission => {
if (permission.startsWith('parent.')) {
if (parentNode) {
const parentQuery = permission.split('.')[1];
return this.nodeHasPermission(parentNode, parentQuery);
}
return false;
}
if (nodes && nodes.length > 0) {
return action.target.multiple
? nodes.some(node =>
this.nodeHasPermission(node.entry, permission)
)
: nodes.length === 1 &&
nodes.every(node =>
this.nodeHasPermission(node.entry, permission)
);
}
return false;
});
}
nodeHasPermissions(node: Node, permissions: string[]): boolean {
if (node && permissions && permissions.length > 0) {
return permissions.some(permission =>
this.nodeHasPermission(node, permission)
);
}
return false;
}
nodeHasPermission(node: Node, permission: string): boolean {
if (node && permission) {
const allowableOperations = node.allowableOperations || [];
return allowableOperations.includes(permission);
}
return false;
}
}

View File

@@ -0,0 +1,72 @@
/*!
* @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 { RuleContext } from './rule-context';
import { RuleParameter } from './rule-parameter';
import { Node } from 'alfresco-js-api';
export function canCreateFolder(context: RuleContext, ...args: RuleParameter[]): boolean {
const folder = context.navigation.currentFolder;
if (folder) {
return nodeHasPermission(folder, 'create');
}
return false;
}
export function canDownloadSelection(context: RuleContext, ...args: RuleParameter[]): boolean {
if (!context.selection.isEmpty) {
return context.selection.nodes.every(node => {
return node.entry && (node.entry.isFile || node.entry.isFolder);
});
}
return false;
}
export function hasFolderSelected(context: RuleContext, ...args: RuleParameter[]): boolean {
const folder = context.selection.folder;
return folder ? true : false;
}
export function hasFileSelected(context: RuleContext, ...args: RuleParameter[]): boolean {
const file = context.selection.file;
return file ? true : false;
}
export function canUpdateSelectedFolder(context: RuleContext, ...args: RuleParameter[]): boolean {
const folder = context.selection.folder;
if (folder && folder.entry) {
return nodeHasPermission(folder.entry, 'update');
}
return false;
}
export function nodeHasPermission(node: Node, permission: string): boolean {
if (node && permission) {
const allowableOperations = node.allowableOperations || [];
return allowableOperations.includes(permission);
}
return false;
}

View File

@@ -0,0 +1,47 @@
/*!
* @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 { RuleContext } from './rule-context';
import { RuleParameter } from './rule-parameter';
export function every(context: RuleContext, ...args: RuleParameter[]): boolean {
if (!args || args.length === 0) {
return false;
}
return args
.map(arg => context.evaluators[arg.value])
.every(evaluator => evaluator(context));
}
export function some(context: RuleContext, ...args: RuleParameter[]): boolean {
if (!args || args.length === 0) {
return false;
}
return args
.map(arg => context.evaluators[arg.value])
.some(evaluator => evaluator(context));
}

View File

@@ -0,0 +1,34 @@
/*!
* @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 { SelectionState } from '../../store/states';
import { RuleEvaluator } from './rule.service';
import { NavigationState } from '../../store/states/navigation.state';
export interface RuleContext {
selection: SelectionState;
navigation: NavigationState;
evaluators: { [key: string]: RuleEvaluator };
}

View File

@@ -0,0 +1,29 @@
/*!
* @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;
}

View File

@@ -0,0 +1,34 @@
/*!
* @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;
}

View File

@@ -0,0 +1,97 @@
/*!
* @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;
}
}

View File

@@ -34,4 +34,5 @@ export const appSelection = createSelector(selectApp, state => state.selection)
export const appLanguagePicker = createSelector(selectApp, state => state.languagePicker);
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);

View File

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