Compare commits

...

2 Commits
master ... dan

Author SHA1 Message Date
Dan Percic
3bd45d958d wip migrations 2024-12-16 11:15:28 +02:00
Dan Percic
36cc0cff3a remove common ui declaration components 2024-12-13 15:17:05 +02:00
21 changed files with 139 additions and 205 deletions

View File

@ -1,101 +0,0 @@
import { inject, ModuleWithProviders, NgModule, Optional, Provider, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { CommonUiOptions, IqserAppConfig, ModuleOptions } from './utils';
import { ConnectionStatusComponent, FullPageErrorComponent } from './error';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ApiPathInterceptor, DefaultUserPreferenceService, IqserConfigService, IqserUserPreferenceService } from './services';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { MatDialogModule } from '@angular/material/dialog';
import { CircleButtonComponent, IconButtonComponent } from './buttons';
import { DomSanitizer } from '@angular/platform-browser';
import { ICONS } from './utils/constants';
import { StopPropagationDirective } from './directives';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
const matModules = [MatIconModule, MatButtonModule, MatDialogModule, MatCheckboxModule, MatTooltipModule, MatProgressBarModule];
const components = [ConnectionStatusComponent, FullPageErrorComponent];
@NgModule({
declarations: [...components],
imports: [
CommonModule,
...matModules,
FormsModule,
ReactiveFormsModule,
TranslateModule,
IconButtonComponent,
CircleButtonComponent,
StopPropagationDirective,
],
exports: [...components],
providers: [
{
provide: HTTP_INTERCEPTORS,
multi: true,
useClass: ApiPathInterceptor,
},
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
],
})
export class CommonUiModule {
constructor(@Optional() @SkipSelf() parentModule?: CommonUiModule) {
if (parentModule) {
throw new Error('CommonUiModule is already loaded. Import it in the AppModule only!');
}
const iconRegistry = inject(MatIconRegistry);
const sanitizer = inject(DomSanitizer);
ICONS.forEach(icon => {
const url = sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/${icon}.svg`);
iconRegistry.addSvgIconInNamespace('iqser', icon, url);
});
}
static forRoot<
UserPreference extends IqserUserPreferenceService,
Config extends IqserConfigService<AppConfig>,
AppConfig extends IqserAppConfig = IqserAppConfig,
>(options: CommonUiOptions<UserPreference, Config, AppConfig>): ModuleWithProviders<CommonUiModule> {
const userPreferenceService = ModuleOptions.getService(
IqserUserPreferenceService,
DefaultUserPreferenceService,
options.existingUserPreferenceService,
);
const configServiceProviders = this._getConfigServiceProviders(options.configServiceFactory, options.configService);
return {
ngModule: CommonUiModule,
providers: [userPreferenceService, ...configServiceProviders],
};
}
private static _getConfigServiceProviders(configServiceFactory: () => unknown, configService?: unknown): Provider[] {
if (configService) {
return [
{
provide: configService,
useFactory: configServiceFactory,
},
{
provide: IqserConfigService,
useExisting: configService,
},
];
}
return [
{
provide: IqserConfigService,
useFactory: configServiceFactory,
},
];
}
}

View File

@ -1,5 +1,6 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NgClass } from '@angular/common';
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { connectionStatusTranslations } from '../../translations';
import { ErrorService } from '../error.service';
@ -16,8 +17,7 @@ import { ErrorService } from '../error.service';
transition('* => online', animate('3s ease-in')),
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
imports: [NgClass],
})
export class ConnectionStatusComponent {
protected readonly connectionStatusTranslations = connectionStatusTranslations;

View File

@ -1,11 +1,11 @@
import { inject, Injectable } from '@angular/core';
import { fromEvent, merge, Observable, Subject } from 'rxjs';
import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
import { LoadingService } from '../loading';
import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
import { NavigationStart, Router } from '@angular/router';
import { shareLast } from '../utils';
import { inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationStart, Router } from '@angular/router';
import { fromEvent, merge, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
import { LoadingService } from '../loading';
import { shareLast } from '../utils';
export class CustomError {
readonly label: string;
@ -39,17 +39,17 @@ const isSameEventType = (previous: Event | string | undefined, current: Event |
@Injectable({ providedIn: 'root' })
export class ErrorService {
readonly offline$: Observable<Event>;
readonly online$: Observable<Event>;
readonly connectionStatus$: Observable<string | undefined>;
readonly #error$ = new Subject<ErrorType>();
readonly error$ = this.#error$.pipe(filter(error => !error || !isOffline(error)));
readonly #error$ = new Subject<ErrorType | undefined>();
readonly #online$ = new Subject();
readonly #loadingService = inject(LoadingService);
readonly #router = inject(Router);
readonly #displayNotification$ = new Subject<string | undefined>();
#notificationTimeout: Record<string, NodeJS.Timeout | undefined> = {};
#displayedNotificationType: string | undefined;
readonly offline$: Observable<Event>;
readonly online$: Observable<Event>;
readonly connectionStatus$: Observable<string | undefined>;
readonly error$ = this.#error$.pipe(filter(error => !error || !isOffline(error)));
constructor() {
this.#router.events

View File

@ -1,15 +1,18 @@
@if (errorService.error$ | async; as error) {
@if (normalizedError(); as normalizedError) {
<section class="full-page-section"></section>
<section class="full-page-content flex-align-items-center">
<mat-icon svgIcon="iqser:failure"></mat-icon>
<div [translate]="errorTitle(error)" class="heading-l mt-24"></div>
@if (error.message) {
<div class="mt-16 error">{{ error.message }}</div>
<div [translate]="normalizedError.title" class="heading-l mt-24"></div>
@if (normalizedError.error.message) {
<div class="mt-16 error">{{ normalizedError.error.message }}</div>
}
<iqser-icon-button
(action)="action(error)"
[icon]="actionIcon(error)"
[label]="actionLabel(error) | translate"
(action)="action(normalizedError.error)"
[icon]="normalizedError.actionIcon"
[label]="normalizedError.actionLabel | translate"
[type]="iconButtonTypes.primary"
class="mt-20"
></iqser-icon-button>

View File

@ -1,30 +1,33 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { IconButtonTypes } from '../../buttons';
import { TranslatePipe } from '@ngx-translate/core';
import { IconButtonComponent, IconButtonTypes } from '../../buttons';
import { CustomError, ErrorService, ErrorType } from '../error.service';
@Component({
selector: 'iqser-full-page-error',
templateUrl: './full-page-error.component.html',
styleUrls: ['./full-page-error.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
imports: [MatIcon, IconButtonComponent, TranslatePipe],
})
export class FullPageErrorComponent {
readonly #error = toSignal(inject(ErrorService).error$);
protected readonly normalizedError = computed(() => {
const error = this.#error();
if (!error) {
return undefined;
}
return {
error: error,
title: error instanceof CustomError ? error.label : _('error.title'),
actionLabel: error instanceof CustomError ? error.actionLabel : _('error.reload'),
actionIcon: error instanceof CustomError ? error.actionIcon : 'iqser:refresh',
};
});
protected readonly iconButtonTypes = IconButtonTypes;
protected readonly errorService = inject(ErrorService);
errorTitle(error: ErrorType): string {
return error instanceof CustomError ? error.label : _('error.title');
}
actionLabel(error: ErrorType): string {
return error instanceof CustomError ? error.actionLabel : _('error.reload');
}
actionIcon(error: ErrorType): string {
return error instanceof CustomError ? error.actionIcon : 'iqser:refresh';
}
action(error: ErrorType): void {
if (error instanceof CustomError && error.action) {

View File

@ -10,10 +10,11 @@ import {
import { Inject, Injectable, Optional } from '@angular/core';
import { finalize, MonoTypeOperatorFunction, Observable, retry, throwError, timer } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { MAX_RETRIES_ON_SERVER_ERROR, SERVER_ERROR_SKIP_PATHS } from './tokens';
import { ErrorService } from './error.service';
import { KeycloakStatusService } from '../tenants';
import { LoadingService } from '../loading';
import { getConfig } from '../services';
import { KeycloakStatusService } from '../tenants';
import { ErrorService } from './error.service';
import { SERVER_ERROR_SKIP_PATHS } from './tokens';
function updateSeconds(seconds: number) {
if (seconds === 0 || seconds === 1) {
@ -52,12 +53,12 @@ function backoffOnServerError(maxRetries = 3, skippedPaths: string[] = []): Mono
@Injectable()
export class ServerErrorInterceptor implements HttpInterceptor {
private readonly _urlsWithError = new Set();
readonly #config = getConfig();
constructor(
private readonly _errorService: ErrorService,
private readonly _loadingService: LoadingService,
private readonly _keycloakStatusService: KeycloakStatusService,
@Optional() @Inject(MAX_RETRIES_ON_SERVER_ERROR) private readonly _maxRetries: number,
@Optional() @Inject(SERVER_ERROR_SKIP_PATHS) private readonly _skippedPaths: string[],
) {}
@ -88,7 +89,7 @@ export class ServerErrorInterceptor implements HttpInterceptor {
}),
);
}),
backoffOnServerError(this._maxRetries, this._skippedPaths),
backoffOnServerError(this.#config.MAX_RETRIES_ON_SERVER_ERROR, this._skippedPaths),
);
}
}

View File

@ -1,4 +1,3 @@
import { InjectionToken } from '@angular/core';
export const MAX_RETRIES_ON_SERVER_ERROR = new InjectionToken<number>('Number of retries before giving up');
export const SERVER_ERROR_SKIP_PATHS = new InjectionToken<string[]>('A list of paths to skip when handling server errors');

View File

@ -1,11 +1,11 @@
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { getConfig } from '../services';
import { IqserUserPreferenceService } from '../services';
import { getConfig, IqserUserPreferenceService } from '../services';
import { HelpModeDialogComponent } from './help-mode-dialog/help-mode-dialog.component';
import { HELP_MODE_KEYS, MANUAL_BASE_URL } from './tokens';
import { HELP_MODE_KEYS } from './tokens';
import { HelpModeKey } from './types';
import {
DOCUMINE_THEME_CLASS,
@ -20,7 +20,6 @@ import {
ScrollableParentViews,
WEB_VIEWER_ELEMENTS,
} from './utils/constants';
import { toSignal } from '@angular/core/rxjs-interop';
export interface Helper {
readonly element: HTMLElement;
@ -39,13 +38,13 @@ export class HelpModeService {
readonly #isDocumine = getConfig().IS_DOCUMINE;
#helpers: Record<string, Helper> = {};
#dialogMode = false;
readonly #config = getConfig();
readonly isHelpModeActive$ = this.#isHelpModeActive$.asObservable();
readonly isHelpModeActive = toSignal(this.isHelpModeActive$, { initialValue: false });
readonly helpModeDialogIsOpened$ = this.#helpModeDialogIsOpened$.asObservable();
constructor(
@Inject(HELP_MODE_KEYS) private readonly _keys: HelpModeKey[],
@Inject(MANUAL_BASE_URL) private readonly _manualBaseURL: string,
private readonly _dialog: MatDialog,
private readonly _rendererFactory: RendererFactory2,
private readonly _translateService: TranslateService,
@ -161,7 +160,7 @@ export class HelpModeService {
#generateDocsLink(key: string) {
const currentLang = this._translateService.currentLang;
return `${this._manualBaseURL}/${currentLang}/index-${currentLang}.html?contextId=${key}`;
return `${this.#config.MANUAL_BASE_URL}/${currentLang}/index-${currentLang}.html?contextId=${key}`;
}
#isElementVisible(helper: Helper): boolean {

View File

@ -1,8 +1,4 @@
import { inject, InjectionToken } from '@angular/core';
import { IqserConfigService } from '../services/iqser-config.service';
import { InjectionToken } from '@angular/core';
import { HelpModeKey } from './types';
export const HELP_MODE_KEYS = new InjectionToken<HelpModeKey>('Help mode keys');
export const MANUAL_BASE_URL = new InjectionToken<string>('Base manual URL', {
factory: () => inject(IqserConfigService).values.MANUAL_BASE_URL,
});

View File

@ -1,7 +1,8 @@
import { makeEnvironmentProviders } from '@angular/core';
import { HelpModeService } from '../help-mode.service';
import { HELP_MODE_KEYS } from '../tokens';
import { HelpModeKey } from '../types';
export function provideHelpMode(helpModeKeys: HelpModeKey[]) {
return [{ provide: HELP_MODE_KEYS, useValue: helpModeKeys }, HelpModeService];
return makeEnvironmentProviders([{ provide: HELP_MODE_KEYS, useValue: helpModeKeys }, HelpModeService]);
}

View File

@ -0,0 +1,17 @@
import { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { getConfig } from '../services/iqser-config.service';
import { UI_ROOT_PATH_FN } from '../utils/tokens';
export const apiPathInterceptorFn: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const config = getConfig();
const convertPath = inject(UI_ROOT_PATH_FN);
if (!req.url.startsWith('/assets')) {
const apiUrl = `${config.API_URL}${req.url}`;
return next(req.clone({ url: apiUrl }));
}
const url = convertPath(req.url);
return next(req.clone({ url }));
};

View File

@ -0,0 +1,49 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer, Provider, Type } from '@angular/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { apiPathInterceptorFn } from './interceptors/api-path.interceptor';
import { CONFIG_SERVICE, DefaultUserPreferenceService, IqserConfigService, IqserUserPreferenceService } from './services';
import { ICONS } from './utils/constants';
type ProvideCommonUiOptions = {
configService: Type<IqserConfigService>;
existingUserPreferenceService: Type<IqserUserPreferenceService>;
};
export function provideCommonUi(options: ProvideCommonUiOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideCommonIcons(),
{
provide: CONFIG_SERVICE,
useExisting: options.configService,
},
provideHttpClient(withInterceptors([apiPathInterceptorFn])),
{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
getService(IqserUserPreferenceService, DefaultUserPreferenceService, options.existingUserPreferenceService),
]);
}
export function getService<B, D, E>(base: B, _default: Type<D>, existing?: E): Provider {
if (existing) {
return {
provide: base,
useExisting: existing,
};
}
return { provide: base, useClass: _default };
}
function provideCommonIcons() {
return provideAppInitializer(() => {
const iconRegistry = inject(MatIconRegistry);
const sanitizer = inject(DomSanitizer);
ICONS.forEach(icon => {
const url = sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/${icon}.svg`);
iconRegistry.addSvgIconInNamespace('iqser', icon, url);
});
});
}

View File

@ -1,21 +0,0 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { UI_ROOT_PATH_FN } from '../utils/tokens';
import { getConfig } from './iqser-config.service';
@Injectable()
export class ApiPathInterceptor implements HttpInterceptor {
readonly #config = getConfig();
readonly #convertPath = inject(UI_ROOT_PATH_FN);
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (!req.url.startsWith('/assets')) {
const apiUrl = `${this.#config.API_URL}${req.url}`;
return next.handle(req.clone({ url: apiUrl }));
}
const url = this.#convertPath(req.url);
return next.handle(req.clone({ url }));
}
}

View File

@ -1,5 +1,3 @@
import exp from 'constants';
export * from './toaster.service';
export * from './error-message.service';
export * from './generic.service';
@ -9,5 +7,4 @@ export * from './entities-map.service';
export * from './iqser-user-preference.service';
export * from './language.service';
export * from './iqser-config.service';
export * from './api-path.interceptor';
export * from './skeleton.service';

View File

@ -1,18 +1,19 @@
import { Inject, inject, Injectable } from '@angular/core';
import { inject, InjectionToken } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { CacheApiService } from '../caching/cache-api.service';
import { wipeAllCaches } from '../caching/cache-utils';
import { IqserAppConfig } from '../utils/iqser-app-config';
import { LANDING_PAGE_THEMES, MANUAL_BASE_URL, THEME_DIRECTORIES } from '../utils/constants';
import { IStoredTenantId, TenantsService } from '../tenants';
import { LANDING_PAGE_THEMES, MANUAL_BASE_URL, THEME_DIRECTORIES } from '../utils/constants';
import { IqserAppConfig } from '../utils/iqser-app-config';
@Injectable()
export class IqserConfigService<T extends IqserAppConfig = IqserAppConfig> {
export const CONFIG_SERVICE = new InjectionToken<IqserConfigService>('IqserConfigService');
export abstract class IqserConfigService<T extends IqserAppConfig = IqserAppConfig> {
protected readonly _cacheApiService = inject(CacheApiService);
protected readonly _titleService = inject(Title);
protected readonly _tenantsService = inject(TenantsService);
constructor(@Inject('Doesnt matter') protected _values: T) {
protected constructor(protected _values: T) {
this._checkFrontendVersion();
this.#updateAppType();
this._titleService.setTitle(this._values.APP_NAME);
@ -73,5 +74,5 @@ export class IqserConfigService<T extends IqserAppConfig = IqserAppConfig> {
}
export function getConfig<T extends IqserAppConfig = IqserAppConfig>() {
return inject<IqserConfigService<T>>(IqserConfigService).values;
return inject<IqserConfigService<T>>(CONFIG_SERVICE).values;
}

View File

@ -1,3 +1,4 @@
export interface IqserTranslateModuleOptions {
readonly pathPrefix?: string;
readonly pathPrefixFactory?: () => string;
}

View File

@ -45,7 +45,10 @@ export class IqserTranslateModule {
providers: [
{
provide: translateLoaderToken,
useFactory: () => pruningTranslationLoaderFactory(pathPrefix),
useFactory: () => {
const prefix = options?.pathPrefixFactory !== undefined ? options.pathPrefixFactory() : pathPrefix;
return pruningTranslationLoaderFactory(prefix);
},
},
{
provide: MissingTranslationHandler,

View File

@ -17,4 +17,3 @@ export * from './context.component';
export * from './tokens';
export * from './module-options';
export * from './iqser-app-config';
export * from './types/common-ui-options';

View File

@ -9,4 +9,5 @@ export interface IqserAppConfig {
readonly MANUAL_BASE_URL: string;
readonly BASE_TRANSLATIONS_DIRECTORY?: string;
readonly LANDING_PAGE_THEME: 'redactmanager' | 'documine' | 'mixed';
readonly MAX_RETRIES_ON_SERVER_ERROR?: number;
}

View File

@ -6,7 +6,7 @@ export const UI_ROOT_PATH_FN = new InjectionToken<(path: string) => string>('App
const root = inject(UI_ROOT);
return (path: string) => {
if (path[0] === '/') {
if (path.startsWith('/')) {
return root + path;
}

View File

@ -1,14 +0,0 @@
import { Type } from '@angular/core';
import { IqserConfigService } from '../../services/iqser-config.service';
import { IqserUserPreferenceService } from '../../services/iqser-user-preference.service';
import { IqserAppConfig } from '../iqser-app-config';
export interface CommonUiOptions<
UserPreference extends IqserUserPreferenceService,
Config extends IqserConfigService<AppConfig>,
AppConfig extends IqserAppConfig,
> {
existingUserPreferenceService?: Type<UserPreference>;
configService?: Type<Config>;
configServiceFactory: () => Config;
}