[AAE-10533] Generic App shell for HxP applications (#2679)

* [AAE-10533] Generic App shell for HxP applications

* refactor

* fix scss mixin path

* remove forRoot in content-plugin

* remove provided routers

* revert router service

* revert template usage

* Added shell markdown

* Move login component to content-plugin

* Moved logic from app.component to app.service

* remove upload-area from shell

* cleaning

* cleaning

* update md

* abstract preferences

* allow to set shell parent route

* fix preferencesService name

* Fix for sidenav

* Fix CR comments

* [ci:force]

* move translation service mock to aca-shared

* fix e2e

* Fix page title

* remove drop area wrapper from whole application

* Fix e2e

* [ci:force]

* Remove blank page from shell

* Add upload files dialog

* [ci:force]

* Remove ExtensionsDataLoaderGuard from shell
This commit is contained in:
Bartosz Sekuła
2022-11-23 14:45:32 +01:00
committed by GitHub
parent 3650aff589
commit 456454fee1
271 changed files with 1959 additions and 1167 deletions

View File

@@ -25,23 +25,91 @@
import { AppService } from './app.service';
import { TestBed } from '@angular/core/testing';
import { AuthenticationService, AppConfigService, AlfrescoApiService, AlfrescoApiServiceMock } from '@alfresco/adf-core';
import { Subject } from 'rxjs';
import {
AuthenticationService,
AppConfigService,
AlfrescoApiService,
PageTitleService,
UserPreferencesService,
UploadService,
SharedLinksApiService,
AlfrescoApiServiceMock,
TranslationMock,
TranslationService,
DiscoveryApiService
} from '@alfresco/adf-core';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { HttpClientModule } from '@angular/common/http';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { GroupService, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router';
import { ContentApiService } from './content-api.service';
import { RouterExtensionService } from './router.extension.service';
import { OverlayContainer } from '@angular/cdk/overlay';
import { AppStore, STORE_INITIAL_APP_DATA } from '../../../store/src/states/app.state';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { CommonModule } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { TranslateServiceMock } from '../testing/translation.service';
import { RouterTestingModule } from '@angular/router/testing';
import { RepositoryInfo } from '@alfresco/js-api';
describe('AppService', () => {
let service: AppService;
let auth: AuthenticationService;
let appConfig: AppConfigService;
let searchQueryBuilderService: SearchQueryBuilderService;
let userPreferencesService: UserPreferencesService;
let router: Router;
let activatedRoute: ActivatedRoute;
let routerExtensionService: RouterExtensionService;
let pageTitleService: PageTitleService;
let uploadService: UploadService;
let contentApiService: ContentApiService;
let sharedLinksApiService: SharedLinksApiService;
let overlayContainer: OverlayContainer;
let alfrescoApiService: AlfrescoApiService;
let groupService: GroupService;
let storeInitialAppData: any;
let store: MockStore<AppStore>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientModule],
imports: [HttpClientModule, RouterTestingModule.withRoutes([])],
providers: [
{ provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock },
CommonModule,
SearchQueryBuilderService,
UserPreferencesService,
RouterExtensionService,
UploadService,
ContentApiService,
SharedLinksApiService,
OverlayContainer,
provideMockStore({}),
{
provide: PageTitleService,
useValue: {}
},
{
provide: DiscoveryApiService,
useValue: {
ecmProductInfo$: new BehaviorSubject<RepositoryInfo>(null),
getEcmProductInfo: (): Observable<RepositoryInfo> => of(new RepositoryInfo({ version: '10.0.0' }))
}
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {}
}
},
{
provide: STORE_INITIAL_APP_DATA,
useValue: {}
},
{
provide: AlfrescoApiService,
useClass: AlfrescoApiServiceMock
},
{
provide: AuthenticationService,
useValue: {
@@ -49,15 +117,47 @@ describe('AppService', () => {
onLogout: new Subject<any>(),
isLoggedIn: () => false
}
}
},
{ provide: TranslationService, useClass: TranslationMock },
{ provide: TranslateService, useClass: TranslateServiceMock }
]
});
auth = TestBed.inject(AuthenticationService);
appConfig = TestBed.inject(AppConfigService);
searchQueryBuilderService = TestBed.inject(SearchQueryBuilderService);
userPreferencesService = TestBed.inject(UserPreferencesService);
router = TestBed.inject(Router);
activatedRoute = TestBed.inject(ActivatedRoute);
routerExtensionService = TestBed.inject(RouterExtensionService);
pageTitleService = TestBed.inject(PageTitleService);
uploadService = TestBed.inject(UploadService);
contentApiService = TestBed.inject(ContentApiService);
sharedLinksApiService = TestBed.inject(SharedLinksApiService);
overlayContainer = TestBed.inject(OverlayContainer);
alfrescoApiService = TestBed.inject(AlfrescoApiService);
groupService = TestBed.inject(GroupService);
storeInitialAppData = TestBed.inject(STORE_INITIAL_APP_DATA);
store = TestBed.inject(MockStore);
auth = TestBed.inject(AuthenticationService);
service = new AppService(auth, appConfig, searchQueryBuilderService);
service = new AppService(
userPreferencesService,
auth,
store,
router,
activatedRoute,
appConfig,
pageTitleService,
alfrescoApiService,
uploadService,
routerExtensionService,
contentApiService,
sharedLinksApiService,
groupService,
overlayContainer,
storeInitialAppData,
searchQueryBuilderService
);
});
it('should be ready if [withCredentials] mode is used', (done) => {
@@ -67,7 +167,25 @@ describe('AppService', () => {
}
};
const instance = new AppService(auth, appConfig, searchQueryBuilderService);
const instance = new AppService(
userPreferencesService,
auth,
store,
router,
activatedRoute,
appConfig,
pageTitleService,
alfrescoApiService,
uploadService,
routerExtensionService,
contentApiService,
sharedLinksApiService,
groupService,
overlayContainer,
storeInitialAppData,
searchQueryBuilderService
);
expect(instance.withCredentials).toBeTruthy();
instance.ready$.subscribe(() => {

View File

@@ -23,18 +23,56 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { AuthenticationService, AppConfigService } from '@alfresco/adf-core';
import { Observable, BehaviorSubject } from 'rxjs';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { Inject, Injectable, OnDestroy } from '@angular/core';
import {
AuthenticationService,
AppConfigService,
AlfrescoApiService,
SharedLinksApiService,
UploadService,
FileUploadErrorEvent,
PageTitleService,
UserPreferencesService
} from '@alfresco/adf-core';
import { Observable, BehaviorSubject, Subject } from 'rxjs';
import { GroupService, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { OverlayContainer } from '@angular/cdk/overlay';
import { ActivatedRoute, ActivationEnd, NavigationStart, Router } from '@angular/router';
import { filter, map, takeUntil, tap } from 'rxjs/operators';
import {
AppState,
AppStore,
CloseModalDialogsAction,
getCustomCssPath,
getCustomWebFontPath,
STORE_INITIAL_APP_DATA,
SetCurrentUrlAction,
SetInitialStateAction,
SetRepositoryInfoAction,
SetUserProfileAction,
SnackbarErrorAction,
ResetSelectionAction
} from '../../../store/src/public-api';
import { ContentApiService } from './content-api.service';
import { RouterExtensionService } from './router.extension.service';
import { Store } from '@ngrx/store';
import { DiscoveryEntry, GroupEntry, Group } from '@alfresco/js-api';
@Injectable({
providedIn: 'root'
})
export class AppService {
// After moving shell to ADF to core, AppService will implement ShellAppService
export class AppService implements OnDestroy {
private ready: BehaviorSubject<boolean>;
ready$: Observable<boolean>;
pageHeading$: Observable<string>;
hideSidenavConditions = ['/preview/'];
minimizeSidenavConditions = ['search'];
onDestroy$ = new Subject<boolean>();
/**
* Whether `withCredentials` mode is enabled.
* Usually means that `Kerberos` mode is used.
@@ -43,16 +81,193 @@ export class AppService {
return this.config.get<boolean>('auth.withCredentials', false);
}
constructor(auth: AuthenticationService, private config: AppConfigService, searchQueryBuilderService: SearchQueryBuilderService) {
this.ready = new BehaviorSubject(auth.isLoggedIn() || this.withCredentials);
constructor(
public preferencesService: UserPreferencesService,
private authenticationService: AuthenticationService,
private store: Store<AppStore>,
private router: Router,
private activatedRoute: ActivatedRoute,
private config: AppConfigService,
private pageTitle: PageTitleService,
private alfrescoApiService: AlfrescoApiService,
private uploadService: UploadService,
private routerExtensionService: RouterExtensionService,
private contentApi: ContentApiService,
private sharedLinksApiService: SharedLinksApiService,
private groupService: GroupService,
private overlayContainer: OverlayContainer,
@Inject(STORE_INITIAL_APP_DATA) private initialAppState: AppState,
searchQueryBuilderService: SearchQueryBuilderService
) {
this.ready = new BehaviorSubject(this.authenticationService.isLoggedIn() || this.withCredentials);
this.ready$ = this.ready.asObservable();
auth.onLogin.subscribe(() => {
this.authenticationService.onLogin.subscribe(() => {
this.ready.next(true);
});
auth.onLogout.subscribe(() => {
this.authenticationService.onLogout.subscribe(() => {
searchQueryBuilderService.resetToDefaults();
});
this.pageHeading$ = this.router.events.pipe(
filter((event) => event instanceof ActivationEnd && event.snapshot.children.length === 0),
map((event: ActivationEnd) => event.snapshot?.data?.title ?? ''),
tap((title) => this.pageTitle.setTitle(title))
);
}
ngOnDestroy(): void {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
init(): void {
this.alfrescoApiService.getInstance().on('error', (error: { status: number; response: any }) => {
if (error.status === 401 && !this.alfrescoApiService.isExcludedErrorListener(error?.response?.req?.url)) {
if (!this.authenticationService.isLoggedIn()) {
this.store.dispatch(new CloseModalDialogsAction());
let redirectUrl = this.activatedRoute.snapshot.queryParams['redirectUrl'];
if (!redirectUrl) {
redirectUrl = this.router.url;
}
this.router.navigate(['/login'], {
queryParams: { redirectUrl }
});
}
}
});
this.loadAppSettings();
this.loadCustomCss();
this.loadCustomWebFont();
const { router } = this;
this.router.events
.pipe(filter((event) => event instanceof ActivationEnd && event.snapshot.children.length === 0))
.subscribe((_event: ActivationEnd) => {
this.store.dispatch(new SetCurrentUrlAction(router.url));
});
this.router.events
.pipe(
filter((event) => event instanceof NavigationStart),
takeUntil(this.onDestroy$)
)
.subscribe(() => {
this.store.dispatch(new ResetSelectionAction());
});
this.routerExtensionService.mapExtensionRoutes();
this.uploadService.fileUploadError.subscribe((error) => this.onFileUploadedError(error));
this.sharedLinksApiService.error.pipe(takeUntil(this.onDestroy$)).subscribe((err: { message: string }) => {
this.store.dispatch(new SnackbarErrorAction(err.message));
});
this.ready$.pipe(takeUntil(this.onDestroy$)).subscribe((isReady) => {
if (isReady) {
this.loadRepositoryStatus();
this.loadUserProfile();
}
});
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
}
private loadRepositoryStatus() {
this.contentApi.getRepositoryInformation().subscribe((response: DiscoveryEntry) => {
this.store.dispatch(new SetRepositoryInfoAction(response.entry.repository));
});
}
private async loadUserProfile() {
const groupsEntries: GroupEntry[] = await this.groupService.listAllGroupMembershipsForPerson('-me-', { maxItems: 250 });
const groups: Group[] = [];
if (groupsEntries) {
groups.push(...groupsEntries.map((obj) => obj.entry));
}
this.contentApi.getPerson('-me-').subscribe((person) => {
this.store.dispatch(new SetUserProfileAction({ person: person.entry, groups }));
});
}
loadAppSettings() {
let baseShareUrl = this.config.get<string>('baseShareUrl');
if (!baseShareUrl.endsWith('/')) {
baseShareUrl += '/';
}
const state: AppState = {
...this.initialAppState,
appName: this.config.get<string>('application.name'),
headerColor: this.config.get<string>('headerColor'),
headerTextColor: this.config.get<string>('headerTextColor', '#000000'),
logoPath: this.config.get<string>('application.logo'),
headerImagePath: this.config.get<string>('application.headerImagePath'),
customCssPath: this.config.get<string>('customCssPath'),
webFontPath: this.config.get<string>('webFontPath'),
sharedUrl: baseShareUrl
};
this.store.dispatch(new SetInitialStateAction(state));
}
onFileUploadedError(error: FileUploadErrorEvent) {
let message = 'APP.MESSAGES.UPLOAD.ERROR.GENERIC';
if (error.error.status === 403) {
message = 'APP.MESSAGES.UPLOAD.ERROR.403';
}
if (error.error.status === 404) {
message = 'APP.MESSAGES.UPLOAD.ERROR.404';
}
if (error.error.status === 409) {
message = 'APP.MESSAGES.UPLOAD.ERROR.CONFLICT';
}
if (error.error.status === 500) {
message = 'APP.MESSAGES.UPLOAD.ERROR.500';
}
if (error.error.status === 504) {
message = 'APP.MESSAGES.UPLOAD.ERROR.504';
}
this.store.dispatch(new SnackbarErrorAction(message));
}
private loadCustomCss(): void {
this.store.select(getCustomCssPath).subscribe((cssPath) => {
if (cssPath) {
this.createLink(cssPath);
}
});
}
private loadCustomWebFont(): void {
this.store.select(getCustomWebFontPath).subscribe((fontUrl) => {
if (fontUrl) {
this.createLink(fontUrl);
}
});
}
private createLink(url: string): void {
const cssLinkElement = document.createElement('link');
cssLinkElement.setAttribute('rel', 'stylesheet');
cssLinkElement.setAttribute('type', 'text/css');
cssLinkElement.setAttribute('href', url);
document.head.appendChild(cssLinkElement);
}
}

View File

@@ -62,7 +62,7 @@ export class RouterExtensionService {
return {
path: route.path,
component: this.getComponentById(route.layout || this.defaults.layout),
component: this.getComponentById(route.layout ?? this.defaults.layout),
canActivateChild: guards,
canActivate: guards,
parentRoute: route.parentRoute,

View File

@@ -0,0 +1,43 @@
/*!
* @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 } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
@Injectable()
export class TranslateServiceMock extends TranslateService {
constructor() {
super(null, null, null, null, null, null, true, null, null);
}
get(key: string | Array<string>): Observable<string | any> {
return of(key);
}
instant(key: string | Array<string>): string | any {
return key;
}
}

View File

@@ -62,3 +62,4 @@ export * from './lib/services/alfresco-office-extension.service';
export * from './lib/utils/node.utils';
export * from './lib/shared.module';
export * from './lib/testing/lib-testing-module';
export * from './lib/testing/translation.service';