diff --git a/angular.json b/angular.json
index af94583e50..4ddab55bc9 100644
--- a/angular.json
+++ b/angular.json
@@ -339,7 +339,9 @@
"lib/core/api/**/*.ts",
"lib/core/api/**/*.html",
"lib/core/auth/**/*.ts",
- "lib/core/auth/**/*.html"
+ "lib/core/auth/**/*.html",
+ "lib/core/shell/**/*.ts",
+ "lib/core/shell/**/*.html"
]
}
},
diff --git a/lib/core/shell/README.md b/lib/core/shell/README.md
new file mode 100644
index 0000000000..1ec3c83f4b
--- /dev/null
+++ b/lib/core/shell/README.md
@@ -0,0 +1,17 @@
+# @alfresco/adf-core/shell
+
+Secondary entry point of `@alfresco/adf-core`. It can be used by importing from `@alfresco/adf-core/shell`.
+
+# Shell
+
+[ShellModule](./src/lib/shell.module.ts) is designated as a main layout for the application.
+
+I order to attach routes to appShell, `withRoutes(routes: Routes | AppShellRoutesConfig)` method should be used.
+
+Passed routes are going to be attached to [shell main route](./src/lib/shell.routes.ts)
+
+If you would like to provide custom app guard, you can provide your own using [SHELL_AUTH_TOKEN](./src/lib/shell.routes.ts)
+
+## Shell Service
+
+In order to use `shell`, you need to provide [SHELL_APP_SERVICE](./src/lib/services/shell-app.service.ts) which provides necessary options for shell component to work.
diff --git a/lib/core/shell/ng-package.json b/lib/core/shell/ng-package.json
new file mode 100644
index 0000000000..1d9f99930d
--- /dev/null
+++ b/lib/core/shell/ng-package.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json",
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/lib/core/shell/public-api.ts b/lib/core/shell/public-api.ts
new file mode 100644
index 0000000000..4600f4568e
--- /dev/null
+++ b/lib/core/shell/public-api.ts
@@ -0,0 +1,18 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './src/index';
diff --git a/lib/core/shell/src/index.ts b/lib/core/shell/src/index.ts
new file mode 100644
index 0000000000..8862dbf042
--- /dev/null
+++ b/lib/core/shell/src/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './lib/shell.module';
+export * from './lib/services/shell-app.service';
+export * from './lib/components/shell/shell.component';
diff --git a/lib/core/shell/src/lib/components/shell/shell.component.html b/lib/core/shell/src/lib/components/shell/shell.component.html
new file mode 100644
index 0000000000..3cd17c11fb
--- /dev/null
+++ b/lib/core/shell/src/lib/components/shell/shell.component.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/core/shell/src/lib/components/shell/shell.component.scss b/lib/core/shell/src/lib/components/shell/shell.component.scss
new file mode 100644
index 0000000000..8373fd4092
--- /dev/null
+++ b/lib/core/shell/src/lib/components/shell/shell.component.scss
@@ -0,0 +1,34 @@
+.app-shell {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100%;
+ overflow: hidden;
+ min-height: 0;
+
+ router-outlet[name='viewer'] + * {
+ width: 100%;
+ height: 100%;
+ z-index: 999;
+ position: absolute;
+ top: 0;
+ right: 0;
+ background-color: white;
+ }
+
+ adf-file-uploading-dialog {
+ z-index: 1000;
+ }
+}
+
+@media screen and (max-width: 599px) {
+ .adf-app-title {
+ display: none;
+ }
+}
+
+@media screen and (max-width: 719px) {
+ .adf-app-logo {
+ display: none;
+ }
+}
diff --git a/lib/core/shell/src/lib/components/shell/shell.component.spec.ts b/lib/core/shell/src/lib/components/shell/shell.component.spec.ts
new file mode 100644
index 0000000000..dd0ccca55a
--- /dev/null
+++ b/lib/core/shell/src/lib/components/shell/shell.component.spec.ts
@@ -0,0 +1,173 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TestBed, ComponentFixture } from '@angular/core/testing';
+import { AppConfigService, SidenavLayoutModule } from '@alfresco/adf-core';
+import { ShellLayoutComponent } from './shell.component';
+import { Router, NavigationStart, RouterModule } from '@angular/router';
+import { of, Subject } from 'rxjs';
+import { ExtensionsModule } from '@alfresco/adf-extensions';
+import { CommonModule } from '@angular/common';
+import { ShellAppService, SHELL_APP_SERVICE } from '../../services/shell-app.service';
+import { HttpClientModule } from '@angular/common/http';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+class MockRouter {
+ private url = 'some-url';
+ private subject = new Subject();
+ events = this.subject.asObservable();
+ routerState = { snapshot: { url: this.url } };
+
+ navigateByUrl(url: string) {
+ const navigationStart = new NavigationStart(0, url);
+ this.subject.next(navigationStart);
+ }
+}
+
+describe('AppLayoutComponent', () => {
+ let fixture: ComponentFixture;
+ let component: ShellLayoutComponent;
+ let appConfig: AppConfigService;
+ let shellAppService: ShellAppService;
+
+ beforeEach(() => {
+ const shellService: ShellAppService = {
+ pageHeading$: of('Title'),
+ hideSidenavConditions: [],
+ minimizeSidenavConditions: [],
+ preferencesService: {
+ get: (_key: string) => 'true',
+ set: (_key: string, _value: any) => {}
+ }
+ };
+
+ TestBed.configureTestingModule({
+ imports: [CommonModule, NoopAnimationsModule, HttpClientModule, SidenavLayoutModule, ExtensionsModule, RouterModule.forChild([])],
+ providers: [
+ {
+ provide: Router,
+ useClass: MockRouter
+ },
+ {
+ provide: SHELL_APP_SERVICE,
+ useValue: shellService
+ }
+ ],
+ declarations: [ShellLayoutComponent],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+
+ fixture = TestBed.createComponent(ShellLayoutComponent);
+ component = fixture.componentInstance;
+ appConfig = TestBed.inject(AppConfigService);
+ shellAppService = TestBed.inject(SHELL_APP_SERVICE);
+ });
+
+ beforeEach(() => {
+ appConfig.config.languages = [];
+ appConfig.config.locale = 'en';
+ });
+
+ describe('sidenav state', () => {
+ it('should get state from configuration', () => {
+ appConfig.config.sideNav = {
+ expandedSidenav: false,
+ preserveState: false
+ };
+
+ fixture.detectChanges();
+
+ expect(component.expandedSidenav).toBe(false);
+ });
+
+ it('should resolve state to true is no configuration', () => {
+ appConfig.config.sidenav = {};
+
+ fixture.detectChanges();
+
+ expect(component.expandedSidenav).toBe(true);
+ });
+
+ it('should get state from user settings as true', () => {
+ appConfig.config.sideNav = {
+ expandedSidenav: false,
+ preserveState: true
+ };
+
+ spyOn(shellAppService.preferencesService, 'get').and.callFake((key) => {
+ if (key === 'expandedSidenav') {
+ return 'true';
+ }
+ return 'false';
+ });
+
+ fixture.detectChanges();
+
+ expect(component.expandedSidenav).toBe(true);
+ });
+
+ it('should get state from user settings as false', () => {
+ appConfig.config.sidenav = {
+ expandedSidenav: false,
+ preserveState: true
+ };
+
+ spyOn(shellAppService.preferencesService, 'get').and.callFake((key) => {
+ if (key === 'expandedSidenav') {
+ return 'false';
+ }
+ return 'true';
+ });
+
+ fixture.detectChanges();
+
+ expect(component.expandedSidenav).toBe(false);
+ });
+ });
+
+ it('should close menu on mobile screen size', () => {
+ component.minimizeSidenav = false;
+ component.layout.container = {
+ isMobileScreenSize: true,
+ toggleMenu: () => {}
+ };
+
+ spyOn(component.layout.container, 'toggleMenu');
+ fixture.detectChanges();
+
+ component.hideMenu({ preventDefault: () => {} } as any);
+
+ expect(component.layout.container.toggleMenu).toHaveBeenCalled();
+ });
+
+ it('should close menu on mobile screen size also when minimizeSidenav true', () => {
+ fixture.detectChanges();
+ component.minimizeSidenav = true;
+ component.layout.container = {
+ isMobileScreenSize: true,
+ toggleMenu: () => {}
+ };
+
+ spyOn(component.layout.container, 'toggleMenu');
+ fixture.detectChanges();
+
+ component.hideMenu({ preventDefault: () => {} } as any);
+
+ expect(component.layout.container.toggleMenu).toHaveBeenCalled();
+ });
+});
diff --git a/lib/core/shell/src/lib/components/shell/shell.component.ts b/lib/core/shell/src/lib/components/shell/shell.component.ts
new file mode 100644
index 0000000000..58eea62978
--- /dev/null
+++ b/lib/core/shell/src/lib/components/shell/shell.component.ts
@@ -0,0 +1,132 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppConfigService, SidenavLayoutComponent } from '@alfresco/adf-core';
+import { Component, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
+import { Subject, Observable } from 'rxjs';
+import { filter, takeUntil, map, withLatestFrom } from 'rxjs/operators';
+import { BreakpointObserver } from '@angular/cdk/layout';
+import { Directionality } from '@angular/cdk/bidi';
+import { SHELL_APP_SERVICE, ShellAppService } from '../../services/shell-app.service';
+
+@Component({
+ selector: 'app-shell',
+ templateUrl: './shell.component.html',
+ styleUrls: ['./shell.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ host: { class: 'app-shell' }
+})
+export class ShellLayoutComponent implements OnInit, OnDestroy {
+ @ViewChild('layout', { static: true })
+ layout: SidenavLayoutComponent;
+
+ onDestroy$: Subject = new Subject();
+ isSmallScreen$: Observable;
+
+ expandedSidenav: boolean;
+ minimizeSidenav = false;
+ hideSidenav = false;
+ direction: Directionality;
+
+ constructor(
+ private router: Router,
+ private appConfigService: AppConfigService,
+ private breakpointObserver: BreakpointObserver,
+ @Inject(SHELL_APP_SERVICE) private shellService: ShellAppService
+ ) {}
+
+ ngOnInit() {
+ this.isSmallScreen$ = this.breakpointObserver.observe(['(max-width: 600px)']).pipe(map((result) => result.matches));
+
+ this.hideSidenav = this.shellService.hideSidenavConditions.some((el) => this.router.routerState.snapshot.url.includes(el));
+ this.minimizeSidenav = this.shellService.minimizeSidenavConditions.some((el) => this.router.routerState.snapshot.url.includes(el));
+
+ if (!this.minimizeSidenav) {
+ this.expandedSidenav = this.getSidenavState();
+ } else {
+ this.expandedSidenav = false;
+ }
+
+ this.router.events
+ .pipe(
+ withLatestFrom(this.isSmallScreen$),
+ filter(([event, isSmallScreen]) => isSmallScreen && event instanceof NavigationEnd),
+ takeUntil(this.onDestroy$)
+ )
+ .subscribe(() => {
+ this.layout.container.sidenav.close();
+ });
+
+ this.router.events
+ .pipe(
+ filter((event) => event instanceof NavigationEnd),
+ takeUntil(this.onDestroy$)
+ )
+ .subscribe((event: NavigationEnd) => {
+ this.minimizeSidenav = this.shellService.minimizeSidenavConditions.some((el) => event.urlAfterRedirects.includes(el));
+ this.hideSidenav = this.shellService.hideSidenavConditions.some((el) => event.urlAfterRedirects.includes(el));
+
+ this.updateState();
+ });
+ }
+
+ ngOnDestroy() {
+ this.onDestroy$.next(true);
+ this.onDestroy$.complete();
+ }
+
+ hideMenu(event: Event) {
+ if (this.layout.container.isMobileScreenSize) {
+ event.preventDefault();
+ this.layout.container.toggleMenu();
+ }
+ }
+
+ private updateState() {
+ if (this.minimizeSidenav && !this.layout.isMenuMinimized) {
+ this.layout.isMenuMinimized = true;
+ if (!this.layout.container.isMobileScreenSize) {
+ this.layout.container.toggleMenu();
+ }
+ }
+
+ if (!this.minimizeSidenav) {
+ if (this.getSidenavState() && this.layout.isMenuMinimized) {
+ this.layout.isMenuMinimized = false;
+ this.layout.container.toggleMenu();
+ }
+ }
+ }
+
+ onExpanded(state: boolean) {
+ if (!this.minimizeSidenav && this.appConfigService.get('sideNav.preserveState')) {
+ this.shellService.preferencesService.set('expandedSidenav', state);
+ }
+ }
+
+ private getSidenavState(): boolean {
+ const expand = this.appConfigService.get('sideNav.expandedSidenav', true);
+ const preserveState = this.appConfigService.get('sideNav.preserveState', true);
+
+ if (preserveState) {
+ return this.shellService.preferencesService.get('expandedSidenav', expand.toString()) === 'true';
+ }
+
+ return expand;
+ }
+}
diff --git a/lib/core/shell/src/lib/services/shell-app.service.ts b/lib/core/shell/src/lib/services/shell-app.service.ts
new file mode 100644
index 0000000000..ede2a5d24d
--- /dev/null
+++ b/lib/core/shell/src/lib/services/shell-app.service.ts
@@ -0,0 +1,36 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { InjectionToken } from '@angular/core';
+import { CanActivate, CanActivateChild } from '@angular/router';
+import { Observable } from 'rxjs';
+
+export interface ShellPreferencesService {
+ set(preferenceKey: string, value: any): void;
+ get(preferenceKey: string, defaultValue: string): string;
+}
+
+export interface ShellAppService {
+ pageHeading$: Observable;
+ hideSidenavConditions: string[];
+ minimizeSidenavConditions: string[];
+ preferencesService: ShellPreferencesService;
+}
+
+export const SHELL_APP_SERVICE = new InjectionToken('SHELL_APP_SERVICE');
+
+export const SHELL_AUTH_TOKEN = new InjectionToken('SHELL_AUTH_TOKEN');
diff --git a/lib/core/shell/src/lib/shell.module.ts b/lib/core/shell/src/lib/shell.module.ts
new file mode 100644
index 0000000000..287c907d19
--- /dev/null
+++ b/lib/core/shell/src/lib/shell.module.ts
@@ -0,0 +1,81 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { CommonModule } from '@angular/common';
+import { ModuleWithProviders, NgModule } from '@angular/core';
+import { Routes, provideRoutes, RouterModule, Route } from '@angular/router';
+import { SHELL_LAYOUT_ROUTE } from './shell.routes';
+import { SidenavLayoutModule } from '@alfresco/adf-core';
+import { ExtensionsModule } from '@alfresco/adf-extensions';
+import { ShellLayoutComponent } from './components/shell/shell.component';
+
+export interface AppShellRoutesConfig {
+ shellParentRoute?: Route;
+ shellChildren: Routes;
+}
+
+@NgModule({
+ imports: [SidenavLayoutModule, ExtensionsModule, RouterModule.forChild([]), CommonModule],
+ exports: [ShellLayoutComponent],
+ declarations: [ShellLayoutComponent]
+})
+export class ShellModule {
+ static withRoutes(routes: Routes | AppShellRoutesConfig): ModuleWithProviders {
+ if (Array.isArray(routes)) {
+ return getModuleForRoutes(routes);
+ }
+
+ return getModuleForRouteConfig(routes);
+ }
+}
+
+function getModuleForRoutes(routes: Routes): ModuleWithProviders {
+ const shellLayoutRoute = SHELL_LAYOUT_ROUTE;
+
+ routes.forEach((childRoute) => {
+ shellLayoutRoute.children.push(childRoute);
+ });
+
+ return {
+ ngModule: ShellModule,
+ providers: provideRoutes([shellLayoutRoute])
+ };
+}
+
+function getModuleForRouteConfig(config: AppShellRoutesConfig): ModuleWithProviders {
+ const shellLayoutRoute = SHELL_LAYOUT_ROUTE;
+
+ const shellParentRoute = config.shellParentRoute;
+ const shellChildrenRoutes = config.shellChildren;
+
+ shellLayoutRoute.children.push(...shellChildrenRoutes);
+
+ const rootRoute = shellParentRoute ? shellParentRoute : shellLayoutRoute;
+
+ if (config.shellParentRoute) {
+ if (rootRoute.children === undefined) {
+ rootRoute.children = [];
+ }
+
+ rootRoute.children.push(shellLayoutRoute);
+ }
+
+ return {
+ ngModule: ShellModule,
+ providers: provideRoutes([rootRoute])
+ };
+}
diff --git a/lib/core/shell/src/lib/shell.routes.ts b/lib/core/shell/src/lib/shell.routes.ts
new file mode 100644
index 0000000000..eef06ce737
--- /dev/null
+++ b/lib/core/shell/src/lib/shell.routes.ts
@@ -0,0 +1,27 @@
+/*!
+ * @license
+ * Copyright 2019 Alfresco Software, Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Route } from '@angular/router';
+import { ShellLayoutComponent } from './components/shell/shell.component';
+import { SHELL_AUTH_TOKEN } from './services/shell-app.service';
+
+export const SHELL_LAYOUT_ROUTE: Route = {
+ path: '',
+ component: ShellLayoutComponent,
+ canActivate: [SHELL_AUTH_TOKEN],
+ children: []
+};
diff --git a/tsconfig.json b/tsconfig.json
index ee21efd17d..fd926c0005 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -26,6 +26,7 @@
"@alfresco/adf-core": ["lib/core"],
"@alfresco/adf-core/*": ["lib/core/*/public-api.ts"],
"@alfresco/adf-core/auth": ["lib/core/auth/src/index.ts"],
+ "@alfresco/adf-core/shell": ["lib/core/shell/src/index.ts"],
"@alfresco/adf-extensions": ["lib/extensions"],
"@alfresco/adf-insights": ["lib/insights"],
"@alfresco/adf-process-services": ["lib/process-services"],