diff --git a/.changeset/spotty-ducks-cheer.md b/.changeset/spotty-ducks-cheer.md new file mode 100644 index 00000000..c138d6be --- /dev/null +++ b/.changeset/spotty-ducks-cheer.md @@ -0,0 +1,9 @@ +--- +'@openstapps/app': minor +--- + +Require full reload for setting & language changes + +Setting changes are relatively rare, so it makes little sense +going through the effort of ensuring everything is reactive to +language changes as well as creating all the pipe bindings etc. diff --git a/README.md b/README.md index 85a81111..dc260e21 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Open StApps Monorepo +# Open StApps Monorepo Refer to the [contribution guide](./CONTRIBUTING.md) diff --git a/frontend/app/package.json b/frontend/app/package.json index 26495fe9..65d49d85 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -93,6 +93,7 @@ "cordova-plugin-calendar": "5.1.6", "date-fns": "2.30.0", "deepmerge": "4.3.1", + "fast-deep-equal": "3.1.3", "form-data": "4.0.0", "geojson": "0.5.0", "ionic-appauth": "0.9.0", diff --git a/frontend/app/src/app/animation/splash.ts b/frontend/app/src/app/animation/splash.ts new file mode 100644 index 00000000..0400dbcd --- /dev/null +++ b/frontend/app/src/app/animation/splash.ts @@ -0,0 +1,80 @@ +import {Animation, AnimationController} from '@ionic/angular'; +import {iosDuration, iosEasing, mdDuration, mdEasing} from './easings'; + +/** + * Splash screen animation + */ +export function splashAnimation(animationCtl: AnimationController): Animation { + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + return animationCtl + .create() + .fromTo('opacity', 0, 1) + .duration(150) + .beforeClearStyles(['visibility']) + .addElement(document.querySelector('ion-app')!); + } + + const isMd = document.querySelector('ion-app.md') !== null; + const navElement = document.querySelector('stapps-navigation-tabs')!; + const navBounds = navElement.getBoundingClientRect(); + let horizontal = navBounds.width < navBounds.height; + if (window.getComputedStyle(navElement).display === 'none') { + horizontal = true; + } + const translate = (amount: number, unit = 'px') => + `translate${horizontal ? 'X' : 'Y'}(${horizontal ? amount * -1 : amount}${unit})`; + const duration = 2 * (isMd ? mdDuration : iosDuration); + + const animation = animationCtl + .create() + .duration(duration) + .easing(isMd ? mdEasing : iosEasing) + .addAnimation( + animationCtl.create().beforeClearStyles(['visibility']).addElement(document.querySelector('ion-app')!), + ) + .addAnimation( + animationCtl + .create() + .fromTo('transform', translate(horizontal ? 64 : 192), translate(0)) + .fromTo('opacity', 0, 1) + .addElement(document.querySelector('stapps-navigation > ion-split-pane')!), + ) + .addAnimation( + animationCtl + .create() + .fromTo('transform', translate(64), translate(0)) + .addElement(document.querySelectorAll('ion-split-pane > ion-menu > ion-content')), + ) + .addAnimation( + animationCtl + .create() + .fromTo('transform', translate(horizontal ? 32 : -72), translate(0)) + .addElement(document.querySelectorAll('ion-router-outlet > .ion-page > ion-content')!), + ) + .addAnimation( + animationCtl + .create() + .fromTo('transform', translate(100, '%'), translate(0, '%')) + .addElement(document.querySelector('stapps-navigation-tabs')!), + ); + + if (!horizontal) { + animation.addAnimation( + animationCtl + .create() + .fromTo('background', 'none', 'none') + .addElement(document.querySelector('ion-router-outlet')!), + ); + + const parallax = document + .querySelector('ion-router-outlet > .ion-page > ion-content') + ?.shadowRoot?.querySelector('[part=parallax]'); + if (parallax) { + animation.addAnimation( + animationCtl.create().fromTo('translate', '0 256px', '0 0px').addElement(parallax), + ); + } + } + + return animation; +} diff --git a/frontend/app/src/app/app.component.ts b/frontend/app/src/app/app.component.ts index ca2b6380..be919c23 100644 --- a/frontend/app/src/app/app.component.ts +++ b/frontend/app/src/app/app.component.ts @@ -22,28 +22,16 @@ import {environment} from '../environments/environment'; import {Capacitor} from '@capacitor/core'; import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; import {Keyboard, KeyboardResize} from '@capacitor/keyboard'; -import {AppVersionService} from './modules/about/app-version.service'; import {SplashScreen} from '@capacitor/splash-screen'; +import {AppVersionService} from './modules/about/app-version.service'; -/** - * TODO - */ @Component({ selector: 'app-root', templateUrl: 'app.component.html', }) export class AppComponent implements AfterContentInit { - /** - * TODO - */ pages: Array<{ - /** - * TODO - */ component: unknown; - /** - * TODO - */ title: string; }>; @@ -65,7 +53,7 @@ export class AppComponent implements AfterContentInit { void this.initializeApp(); } - async ngAfterContentInit() { + ngAfterContentInit() { this.scheduleSyncService.init(); void this.scheduleSyncService.enable(); this.versionService.getPendingReleaseNotes().then(notes => { @@ -74,24 +62,11 @@ export class AppComponent implements AfterContentInit { } }); - if (document.readyState === 'complete') { - this.hideSplash(); - } else { - document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') this.hideSplash(); - }); - } - } - - async hideSplash() { if (Capacitor.isNativePlatform()) { void SplashScreen.hide(); } } - /** - * TODO - */ async initializeApp() { App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => { this.zone.run(() => { diff --git a/frontend/app/src/app/app.module.ts b/frontend/app/src/app/app.module.ts index fd6549a6..1473aecf 100644 --- a/frontend/app/src/app/app.module.ts +++ b/frontend/app/src/app/app.module.ts @@ -25,12 +25,10 @@ import moment from 'moment'; import 'moment/min/locales'; import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger'; import SwiperCore, {FreeMode, Navigation} from 'swiper'; - import {environment} from '../environments/environment'; import {AppRoutingModule} from './app-routing.module'; import {AppComponent} from './app.component'; import {CatalogModule} from './modules/catalog/catalog.module'; -import {ConfigModule} from './modules/config/config.module'; import {ConfigProvider} from './modules/config/config.provider'; import {DashboardModule} from './modules/dashboard/dashboard.module'; import {DataModule} from './modules/data/data.module'; @@ -44,7 +42,6 @@ import {SettingsProvider} from './modules/settings/settings.provider'; import {StorageModule} from './modules/storage/storage.module'; import {ThingTranslateModule} from './translation/thing-translate.module'; import {UtilModule} from './util/util.module'; -import {initLogger} from './_helpers/ts-logger'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {AboutModule} from './modules/about/about.module'; import {JobModule} from './modules/jobs/jobs.module'; @@ -91,28 +88,25 @@ export function initializerFactory( ) { return async () => { try { - initLogger(logger); await storageProvider.init(); await configProvider.init(); - await settingsProvider.init(); + if (configProvider.firstSession) { + // set language from browser + await settingsProvider.setSettingValue( + 'profile', + 'language', + translateService.getBrowserLang() as SCSettingValue, + ); + } + const languageCode = await settingsProvider.getSetting('profile', 'language'); + // this language will be used as a fallback when a translation isn't found in the current language + translateService.setDefaultLang('en'); + translateService.use(languageCode); + moment.locale(languageCode); + const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode); + setDefaultOptions({locale: dateFnsLocale}); + dateFnsConfigurationService.setLocale(dateFnsLocale); try { - if (configProvider.firstSession) { - // set language from browser - await settingsProvider.setSettingValue( - 'profile', - 'language', - translateService.getBrowserLang() as SCSettingValue, - ); - } - const languageCode = (await settingsProvider.getValue('profile', 'language')) as string; - // this language will be used as a fallback when a translation isn't found in the current language - translateService.setDefaultLang('en'); - translateService.use(languageCode); - moment.locale(languageCode); - const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode); - setDefaultOptions({locale: dateFnsLocale}); - dateFnsConfigurationService.setLocale(dateFnsLocale); - await defaultAuthService.init(); await paiaAuthService.init(); } catch (error) { @@ -151,11 +145,12 @@ export function createTranslateLoader(http: HttpClient) { BrowserAnimationsModule, CatalogModule, CommonModule, - ConfigModule, DashboardModule, DataModule, HebisModule, - IonicModule.forRoot(), + IonicModule.forRoot({ + animated: 'Cypress' in window ? false : undefined, + }), IonIconModule, JobModule, FavoritesModule, diff --git a/frontend/app/src/app/modules/about/about-page/about-page.component.ts b/frontend/app/src/app/modules/about/about-page/about-page.component.ts index ea0d5923..ed30c180 100644 --- a/frontend/app/src/app/modules/about/about-page/about-page.component.ts +++ b/frontend/app/src/app/modules/about/about-page/about-page.component.ts @@ -14,7 +14,7 @@ */ import {Component, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {SCAboutPage, SCAppConfiguration} from '@openstapps/core'; +import {SCAboutPage} from '@openstapps/core'; import {ConfigProvider} from '../../config/config.provider'; import packageJson from '../../../../../package.json'; import config from 'capacitor.config'; @@ -42,8 +42,7 @@ export class AboutPageComponent implements OnInit { async ngOnInit() { const route = this.route.snapshot.url.map(it => it.path).join('/'); - this.content = - (this.configProvider.getValue('aboutPages') as SCAppConfiguration['aboutPages'])[route] ?? {}; + this.content = this.configProvider.config.app.aboutPages[route] ?? {}; this.version = Capacitor.getPlatform() === 'web' ? 'Web' : await App.getInfo().then(info => info.version); } } diff --git a/frontend/app/src/app/modules/about/about.module.ts b/frontend/app/src/app/modules/about/about.module.ts index 5c975fa3..0dc3876d 100644 --- a/frontend/app/src/app/modules/about/about.module.ts +++ b/frontend/app/src/app/modules/about/about.module.ts @@ -19,7 +19,6 @@ import {FormsModule} from '@angular/forms'; import {IonicModule} from '@ionic/angular'; import {TranslateModule} from '@ngx-translate/core'; import {ThingTranslateModule} from '../../translation/thing-translate.module'; -import {ConfigProvider} from '../config/config.provider'; import {AboutPageComponent} from './about-page/about-page.component'; import {MarkdownModule} from 'ngx-markdown'; import {AboutPageContentComponent} from './about-page/about-page-content.component'; @@ -64,6 +63,5 @@ const settingsRoutes: Routes = [ ScrollingModule, UtilModule, ], - providers: [ConfigProvider], }) export class AboutModule {} diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.ts b/frontend/app/src/app/modules/auth/auth-helper.service.ts index 39994816..df3a3482 100644 --- a/frontend/app/src/app/modules/auth/auth-helper.service.ts +++ b/frontend/app/src/app/modules/auth/auth-helper.service.ts @@ -18,12 +18,7 @@ import {IPAIAAuthAction} from './paia/paia-auth-action'; import {AuthActions, IAuthAction} from 'ionic-appauth'; import {TranslateService} from '@ngx-translate/core'; import {JSONPath} from 'jsonpath-plus'; -import { - SCAuthorizationProvider, - SCAuthorizationProviderType, - SCUserConfiguration, - SCUserConfigurationMap, -} from '@openstapps/core'; +import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core'; import {ConfigProvider} from '../config/config.provider'; import {StorageProvider} from '../storage/storage.provider'; import {DefaultAuthService} from './default-auth.service'; @@ -37,8 +32,6 @@ const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path'; providedIn: 'root', }) export class AuthHelperService { - userConfigurationMap: SCUserConfigurationMap; - constructor( private translateService: TranslateService, private configProvider: ConfigProvider, @@ -47,14 +40,7 @@ export class AuthHelperService { private paiaAuth: PAIAAuthService, private browser: SimpleBrowser, private alertController: AlertController, - ) { - this.userConfigurationMap = - ( - this.configProvider.getAnyValue('auth') as { - default: SCAuthorizationProvider; - } - ).default?.endpoints.mapping ?? {}; - } + ) {} public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction | IPAIAAuthAction) { let message: string | undefined; @@ -77,9 +63,10 @@ export class AuthHelperService { name: '', role: 'student', }; - for (const key in this.userConfigurationMap) { + const mapping = this.configProvider.config.auth.default!.endpoints.mapping; + for (const key in mapping) { user[key as keyof SCUserConfiguration] = JSONPath({ - path: this.userConfigurationMap[key as keyof SCUserConfiguration] as string, + path: mapping[key as keyof SCUserConfiguration] as string, json: userInfo, preventEval: true, })[0]; diff --git a/frontend/app/src/app/modules/auth/default-auth.service.ts b/frontend/app/src/app/modules/auth/default-auth.service.ts index 701365a2..3c81d54f 100644 --- a/frontend/app/src/app/modules/auth/default-auth.service.ts +++ b/frontend/app/src/app/modules/auth/default-auth.service.ts @@ -12,7 +12,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import { AuthorizationRequestHandler, AuthorizationServiceConfiguration, @@ -24,7 +23,6 @@ import { } from '@openid/appauth'; import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth'; import {ConfigProvider} from '../config/config.provider'; -import {SCAuthorizationProvider} from '@openstapps/core'; import {getClientConfig, getEndpointsConfig} from './auth.provider.methods'; import {Injectable} from '@angular/core'; import {AuthService} from './auth.service'; @@ -67,12 +65,9 @@ export class DefaultAuthService extends AuthService { } setupConfiguration() { - const authConfig = this.configProvider.getAnyValue('auth') as { - default: SCAuthorizationProvider; - }; - this.authConfig = getClientConfig('default', authConfig); + this.authConfig = getClientConfig('default', this.configProvider.config.auth); this.localConfiguration = new AuthorizationServiceConfiguration( - getEndpointsConfig('default', authConfig), + getEndpointsConfig('default', this.configProvider.config.auth), ); } diff --git a/frontend/app/src/app/modules/auth/paia/paia-auth.service.ts b/frontend/app/src/app/modules/auth/paia/paia-auth.service.ts index 252ab1eb..64db03b8 100644 --- a/frontend/app/src/app/modules/auth/paia/paia-auth.service.ts +++ b/frontend/app/src/app/modules/auth/paia/paia-auth.service.ts @@ -12,7 +12,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import { AuthorizationError, AuthorizationRequest, @@ -47,7 +46,6 @@ import {PAIAAuthorizationResponse} from './paia-authorization-response'; import {PAIAAuthorizationNotifier} from './paia-authorization-notifier'; import {PAIATokenResponse} from './paia-token-response'; import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action'; -import {SCAuthorizationProvider} from '@openstapps/core'; import {ConfigProvider} from '../../config/config.provider'; import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods'; import {Injectable} from '@angular/core'; @@ -154,11 +152,10 @@ export class PAIAAuthService { } setupConfiguration() { - const authConfig = this.configProvider.getAnyValue('auth') as { - paia: SCAuthorizationProvider; - }; - this.authConfig = getClientConfig('paia', authConfig); - this.localConfiguration = new AuthorizationServiceConfiguration(getEndpointsConfig('paia', authConfig)); + this.authConfig = getClientConfig('paia', this.configProvider.config.auth); + this.localConfiguration = new AuthorizationServiceConfiguration( + getEndpointsConfig('paia', this.configProvider.config.auth), + ); } protected notifyActionListers(action: IPAIAAuthAction) { diff --git a/frontend/app/src/app/modules/calendar/calendar.service.ts b/frontend/app/src/app/modules/calendar/calendar.service.ts index ddcd31c5..3a69ac02 100644 --- a/frontend/app/src/app/modules/calendar/calendar.service.ts +++ b/frontend/app/src/app/modules/calendar/calendar.service.ts @@ -12,7 +12,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {Calendar} from '@awesome-cordova-plugins/calendar/ngx'; import {Injectable} from '@angular/core'; import {ICalEvent} from './ical/ical'; @@ -35,14 +34,14 @@ export class CalendarService { goToDateClicked = this.goToDate.asObservable(); - calendarName = 'StApps'; + calendarName: string; // eslint-disable-next-line @typescript-eslint/no-empty-function constructor( readonly calendar: Calendar, private readonly configProvider: ConfigProvider, ) { - this.calendarName = (this.configProvider.getValue('name') as string) ?? 'StApps'; + this.calendarName = this.configProvider.config.app.name ?? 'StApps'; } async createCalendar(): Promise { diff --git a/frontend/app/src/app/modules/catalog/catalog.module.ts b/frontend/app/src/app/modules/catalog/catalog.module.ts index 88644e69..6e701157 100644 --- a/frontend/app/src/app/modules/catalog/catalog.module.ts +++ b/frontend/app/src/app/modules/catalog/catalog.module.ts @@ -20,7 +20,6 @@ import {IonicModule} from '@ionic/angular'; import {TranslateModule} from '@ngx-translate/core'; import {MomentModule} from 'ngx-moment'; import {DataModule} from '../data/data.module'; -import {SettingsProvider} from '../settings/settings.provider'; import {CatalogComponent} from './catalog.component'; import {UtilModule} from '../../util/util.module'; import {IonIconModule} from '../../util/ion-icon/ion-icon.module'; @@ -46,6 +45,5 @@ const catalogRoutes: Routes = [ DataModule, UtilModule, ], - providers: [SettingsProvider], }) export class CatalogModule {} diff --git a/frontend/app/src/app/modules/config/config.provider.spec.ts b/frontend/app/src/app/modules/config/config.provider.spec.ts index c271f153..ee7672b1 100644 --- a/frontend/app/src/app/modules/config/config.provider.spec.ts +++ b/frontend/app/src/app/modules/config/config.provider.spec.ts @@ -16,12 +16,6 @@ import {TestBed} from '@angular/core/testing'; import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; import {StorageProvider} from '../storage/storage.provider'; import {ConfigProvider, STORAGE_KEY_CONFIG} from './config.provider'; -import { - ConfigFetchError, - ConfigInitError, - SavedConfigNotAvailable, - WrongConfigVersionInStorage, -} from './errors'; import {NGXLogger} from 'ngx-logger'; import {sampleIndexResponse} from '../../_helpers/data/sample-configuration'; diff --git a/frontend/app/src/app/modules/config/config.provider.ts b/frontend/app/src/app/modules/config/config.provider.ts index 259f1790..b292ebe2 100644 --- a/frontend/app/src/app/modules/config/config.provider.ts +++ b/frontend/app/src/app/modules/config/config.provider.ts @@ -14,19 +14,14 @@ */ import {Injectable} from '@angular/core'; import {Client} from '@openstapps/api'; -import {SCAppConfiguration, SCIndexResponse} from '@openstapps/core'; +import {SCIndexResponse} from '@openstapps/core'; import packageInfo from '@openstapps/core/package.json'; import {NGXLogger} from 'ngx-logger'; import {environment} from '../../../environments/environment'; import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; import {StorageProvider} from '../storage/storage.provider'; -import { - ConfigFetchError, - ConfigInitError, - ConfigValueNotAvailable, - SavedConfigNotAvailable, - WrongConfigVersionInStorage, -} from './errors'; +import equals from 'fast-deep-equal/es6'; +import {BehaviorSubject} from 'rxjs'; /** * Key to store config in storage module @@ -35,6 +30,17 @@ import { */ export const STORAGE_KEY_CONFIG = 'stapps.config'; +/** + * Makes an object deeply immutable + */ +function deepFreeze(object: T) { + for (const key of Object.keys(object)) { + const value = (object as Record)[key]; + if (typeof value === 'object' && !Object.isFrozen(value)) deepFreeze(value!); + } + return Object.freeze(object); +} + /** * Provides configuration */ @@ -50,7 +56,7 @@ export class ConfigProvider { /** * App configuration as IndexResponse */ - config: SCIndexResponse; + config: Readonly; /** * Version of the @openstapps/core package that app is using @@ -62,6 +68,11 @@ export class ConfigProvider { */ firstSession = true; + /** + * If the config requires an update + */ + needsUpdate$ = new BehaviorSubject(false); + /** * Constructor, initialise api client * @param storageProvider StorageProvider to load persistent configuration @@ -76,104 +87,35 @@ export class ConfigProvider { this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version); } - /** - * Fetches configuration from backend - */ - async fetch(): Promise { - try { - return await this.client.handshake(this.scVersion); - } catch { - throw new ConfigFetchError(); - } - } - - /** - * Returns the value of an app configuration - * @param attribute requested attribute from app configuration - */ - public getValue(attribute: keyof SCAppConfiguration) { - if (this.config.app[attribute] !== undefined) { - return this.config.app[attribute]; - } - throw new ConfigValueNotAvailable(attribute); - } - - /** - * Returns a value of the configuration (not only app configuration) - * @param attribute requested attribute from the configuration - */ - public getAnyValue(attribute: keyof SCIndexResponse) { - if (this.config[attribute] !== undefined) { - return this.config[attribute]; - } - throw new ConfigValueNotAvailable(attribute); - } - /** * Initialises the ConfigProvider - * @throws ConfigInitError if no configuration could be loaded. - * @throws WrongConfigVersionInStorage if fetch failed and saved config has wrong SCVersion */ async init(): Promise { - let loadError; - let fetchError; - // load saved configuration - try { - this.config = await this.loadLocal(); - this.firstSession = false; - this.logger.log(`initialised configuration from storage`); - if (this.config.backend.SCVersion !== this.scVersion) { - loadError = new WrongConfigVersionInStorage(this.scVersion, this.config.backend.SCVersion); + this.config = (await this.storageProvider.has(STORAGE_KEY_CONFIG)) + ? await this.storageProvider.get(STORAGE_KEY_CONFIG) + : undefined!; + this.firstSession = !this.config; + + const updatedConfig = this.client.handshake(this.scVersion).then(async fetchedConfig => { + if (!equals(fetchedConfig, this.config)) { + await this.storageProvider.put(STORAGE_KEY_CONFIG, fetchedConfig); + this.logger.log(`Config updated`); + this.needsUpdate$.next(true); + this.needsUpdate$.complete(); } - } catch (error) { - loadError = error; - } - // fetch remote configuration from backend - try { - const fetchedConfig: SCIndexResponse = await this.fetch(); - await this.set(fetchedConfig); - this.logger.log(`initialised configuration from remote`); - } catch (error) { - fetchError = error; - } - // check for occurred errors and throw them - if (loadError !== undefined && fetchError !== undefined) { - throw new ConfigInitError(); - } - if (loadError !== undefined) { - this.logger.warn(loadError); - } - if (fetchError !== undefined) { - this.logger.warn(fetchError); - } - } + return fetchedConfig; + }); - /** - * Returns saved configuration from StorageModule - * @throws SavedConfigNotAvailable if no configuration could be loaded - */ - async loadLocal(): Promise { - // get local configuration - if (await this.storageProvider.has(STORAGE_KEY_CONFIG)) { - return this.storageProvider.get(STORAGE_KEY_CONFIG); + this.config ??= await updatedConfig; + this.config = deepFreeze(this.config); + + if (this.config.backend.SCVersion !== this.scVersion) { + this.logger.warn( + 'Incompatible config, expected', + this.scVersion, + 'but got', + this.config.backend.SCVersion, + ); } - throw new SavedConfigNotAvailable(); - } - - /** - * Saves the configuration from the provider - * @param config configuration to save - */ - async save(config: SCIndexResponse): Promise { - await this.storageProvider.put(STORAGE_KEY_CONFIG, config); - } - - /** - * Sets the configuration in the module and writes it into app storage - * @param config SCIndexResponse to set - */ - async set(config: SCIndexResponse): Promise { - this.config = config; - await this.save(this.config); } } diff --git a/frontend/app/src/app/modules/config/errors.ts b/frontend/app/src/app/modules/config/errors.ts deleted file mode 100644 index f9fcd84d..00000000 --- a/frontend/app/src/app/modules/config/errors.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2022 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -import {AppError} from '../../_helpers/errors'; - -/** - * Error that is thrown when fetching from backend fails - */ -export class ConfigFetchError extends AppError { - constructor() { - super('ConfigFetchError', 'App configuration could not be fetched!'); - } -} - -/** - * Error that is thrown when the ConfigProvider could be initialised - */ -export class ConfigInitError extends AppError { - constructor() { - super('ConfigInitError', 'App configuration could not be initialised!'); - } -} - -/** - * Error that is thrown when the requested config value is not available - */ -export class ConfigValueNotAvailable extends AppError { - constructor(valueKey: string) { - super('ConfigValueNotAvailable', `No attribute "${valueKey}" in config available!`); - } -} - -/** - * Error that is thrown when no saved config is available - */ -export class SavedConfigNotAvailable extends AppError { - constructor() { - super('SavedConfigNotAvailable', 'No saved app configuration available.'); - } -} - -/** - * Error that is thrown when the SCVersion of the saved config is not compatible with the app - */ -export class WrongConfigVersionInStorage extends AppError { - constructor(correctVersion: string, savedVersion: string) { - super( - 'WrongConfigVersionInStorage', - `The saved configs backend version ${savedVersion} ` + - `does not equal the configured backend version ${correctVersion} of the app.`, - ); - } -} diff --git a/frontend/app/src/app/modules/dashboard/dashboard.module.ts b/frontend/app/src/app/modules/dashboard/dashboard.module.ts index a39c0daa..1ed36fa7 100644 --- a/frontend/app/src/app/modules/dashboard/dashboard.module.ts +++ b/frontend/app/src/app/modules/dashboard/dashboard.module.ts @@ -21,7 +21,6 @@ import {SwiperModule} from 'swiper/angular'; import {TranslateModule, TranslatePipe} from '@ngx-translate/core'; import {MomentModule} from 'ngx-moment'; import {DataModule} from '../data/data.module'; -import {SettingsProvider} from '../settings/settings.provider'; import {DashboardComponent} from './dashboard.component'; import {SearchSectionComponent} from './sections/search-section/search-section.component'; import {NewsSectionComponent} from './sections/news-section/news-section.component'; @@ -70,6 +69,6 @@ const catalogRoutes: Routes = [ NewsModule, JobModule, ], - providers: [SettingsProvider, TranslatePipe], + providers: [TranslatePipe], }) export class DashboardModule {} diff --git a/frontend/app/src/app/modules/data/data.module.ts b/frontend/app/src/app/modules/data/data.module.ts index 09697966..ea7e5a66 100644 --- a/frontend/app/src/app/modules/data/data.module.ts +++ b/frontend/app/src/app/modules/data/data.module.ts @@ -32,7 +32,6 @@ import {ScheduleProvider} from '../calendar/schedule.provider'; import {GeoNavigationDirective} from '../map/geo-navigation.directive'; import {MapWidgetComponent} from '../map/widget/map-widget.component'; import {MenuModule} from '../menu/menu.module'; -import {SettingsProvider} from '../settings/settings.provider'; import {StorageModule} from '../storage/storage.module'; import {ActionChipListComponent} from './chips/action-chip-list.component'; import {AddEventActionChipComponent} from './chips/data/add-event-action-chip.component'; @@ -214,7 +213,6 @@ import {ShareButtonComponent} from './elements/share-button.component'; StAppsWebHttpClient, CalendarService, RoutingStackService, - SettingsProvider, { provide: SimpleBrowser, useFactory: browserFactory, diff --git a/frontend/app/src/app/modules/data/detail/data-detail.component.ts b/frontend/app/src/app/modules/data/detail/data-detail.component.ts index 9ac757b4..4a69a1b9 100644 --- a/frontend/app/src/app/modules/data/detail/data-detail.component.ts +++ b/frontend/app/src/app/modules/data/detail/data-detail.component.ts @@ -15,13 +15,13 @@ import {Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {ModalController} from '@ionic/angular'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import {SCLanguageCode, SCSaveableThing, SCThings, SCUuid} from '@openstapps/core'; +import {SCSaveableThing, SCThings, SCUuid} from '@openstapps/core'; import {DataProvider, DataScope} from '../data.provider'; import {FavoritesService} from '../../favorites/favorites.service'; import {take} from 'rxjs/operators'; import {Network} from '@capacitor/network'; import {DataListContext} from '../list/data-list.component'; +import {lastValueFrom} from 'rxjs'; export interface ExternalDataLoadEvent { uid: SCUuid; @@ -29,6 +29,13 @@ export interface ExternalDataLoadEvent { resolve: (item: SCThings | null | undefined) => void; } +/** + * Type guard for SCSavableThing + */ +function isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing { + return (thing as SCSaveableThing).data !== undefined; +} + /** * A Component to display an SCThing detailed */ @@ -53,11 +60,6 @@ export class DataDetailComponent implements OnInit { @Input() autoRouteDataPath = true; - /** - * The language of the item - */ - language: SCLanguageCode; - /** * Indicating wether internet connectivity is given or not */ @@ -79,20 +81,12 @@ export class DataDetailComponent implements OnInit { @Output() loadItem: EventEmitter = new EventEmitter(); - /** - * Type guard for SCSavableThing - */ - static isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing { - return (thing as SCSaveableThing).data !== undefined; - } - constructor( protected readonly route: ActivatedRoute, router: Router, private readonly dataProvider: DataProvider, private readonly favoritesService: FavoritesService, readonly modalController: ModalController, - translateService: TranslateService, ) { this.inputItem = router.getCurrentNavigation()?.extras.state?.item; if (!this.inputItem?.origin) { @@ -100,10 +94,6 @@ export class DataDetailComponent implements OnInit { // This can happen, for example, when detail views use `inPlace` list items delete this.inputItem; } - this.language = translateService.currentLang as SCLanguageCode; - translateService.onLangChange.subscribe((event: LangChangeEvent) => { - this.language = event.lang as SCLanguageCode; - }); this.isDisconnected = new Promise(async resolve => { const isConnected = (await Network.getStatus()).connected; @@ -126,13 +116,8 @@ export class DataDetailComponent implements OnInit { ) : this.dataProvider.get(uid, DataScope.Remote))); - this.item = item - ? // eslint-disable-next-line unicorn/no-null - DataDetailComponent.isSCSavableThing(item) - ? item.data - : item - : // eslint-disable-next-line unicorn/no-null - null; + // eslint-disable-next-line unicorn/no-null + this.item = item ? (isSCSavableThing(item) ? item.data : item) : null; } catch { // eslint-disable-next-line unicorn/no-null this.item = null; @@ -144,14 +129,10 @@ export class DataDetailComponent implements OnInit { await this.getItem(uid ?? '', false); // fallback to the saved item (from favorites) if (this.item === null) { - this.favoritesService - .get(uid) - .pipe(take(1)) - .subscribe(item => { - if (item !== undefined) { - this.item = item.data; - } - }); + const item = await lastValueFrom(this.favoritesService.get(uid).pipe(take(1))); + if (item) { + this.item = item.data; + } } } } diff --git a/frontend/app/src/app/modules/data/elements/offers-in-list.component.ts b/frontend/app/src/app/modules/data/elements/offers-in-list.component.ts index 57281fe8..52085807 100644 --- a/frontend/app/src/app/modules/data/elements/offers-in-list.component.ts +++ b/frontend/app/src/app/modules/data/elements/offers-in-list.component.ts @@ -30,8 +30,8 @@ export class OffersInListComponent { @Input() set offers(it: Array>) { this._offers = it; this.price = it[0].prices?.default; - this.settingsProvider.getSetting('profile', 'group').then(group => { - this.price = it[0].prices?.[(group.value as string).replace(/s$/, '') as never]; + this.settingsProvider.getSetting('profile', 'group').then(group => { + this.price = it[0].prices?.[group.replace(/s$/, '') as never]; }); const availabilities = new Set(it.map(offer => offer.availability)); diff --git a/frontend/app/src/app/modules/data/list/search-page.component.ts b/frontend/app/src/app/modules/data/list/search-page.component.ts index 90ff6d8b..5bc548df 100644 --- a/frontend/app/src/app/modules/data/list/search-page.component.ts +++ b/frontend/app/src/app/modules/data/list/search-page.component.ts @@ -17,14 +17,7 @@ import {ActivatedRoute, Router} from '@angular/router'; import {Keyboard} from '@capacitor/keyboard'; import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular'; import {Capacitor} from '@capacitor/core'; -import { - SCFacet, - SCFeatureConfiguration, - SCSearchFilter, - SCSearchQuery, - SCSearchSort, - SCThings, -} from '@openstapps/core'; +import {SCFacet, SCSearchFilter, SCSearchQuery, SCSearchSort, SCThings} from '@openstapps/core'; import {NGXLogger} from 'ngx-logger'; import {combineLatest, Subject} from 'rxjs'; import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators'; @@ -170,9 +163,8 @@ export class SearchPageComponent implements OnInit { private readonly route: ActivatedRoute, protected positionService: PositionService, private readonly configProvider: ConfigProvider, - animationController: AnimationController, ) { - this.routeAnimation = searchPageSwitchAnimation(animationController); + this.routeAnimation = searchPageSwitchAnimation(inject(AnimationController)); } /** @@ -323,16 +315,6 @@ export class SearchPageComponent implements OnInit { this.queryChanged.next(); } }); - this.settingsProvider.settingsActionChanged$ - .pipe(takeUntilDestroyed(this.destroy$)) - .subscribe(({type, payload}) => { - if (type === 'stapps.settings.changed') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {category, name, value} = payload!; - this.logger.log(`received event "settings.changed" with category: - ${category}, name: ${name}, value: ${JSON.stringify(value)}`); - } - }); this.dataRoutingService .itemSelectListener() .pipe(takeUntilDestroyed(this.destroy$)) @@ -342,12 +324,8 @@ export class SearchPageComponent implements OnInit { } }); } - try { - const features = this.configProvider.getValue('features') as SCFeatureConfiguration; - this.isHebisAvailable = !!features.plugins?.['hebis-plugin']?.urlPath; - } catch (error) { - this.logger.error(error); - } + this.isHebisAvailable = + this.configProvider.config.app.features.plugins?.['hebis-plugin']?.urlPath !== undefined; } /** diff --git a/frontend/app/src/app/modules/data/rating.provider.ts b/frontend/app/src/app/modules/data/rating.provider.ts index 3841acc6..6448dec1 100644 --- a/frontend/app/src/app/modules/data/rating.provider.ts +++ b/frontend/app/src/app/modules/data/rating.provider.ts @@ -21,7 +21,6 @@ import { SCRatingResponse, SCRatingRoute, SCUserGroup, - SCUserGroupSetting, SCUuid, } from '@openstapps/core'; import {StAppsWebHttpClient} from './stapps-web-http-client.provider'; @@ -63,9 +62,7 @@ export class RatingProvider { } private get userGroup(): Promise { - return this.settingsProvider - .getSetting('profile', 'group') - .then(it => (it as SCUserGroupSetting).value as SCUserGroup); + return this.settingsProvider.getSetting('profile', 'group'); } private async getStoredRatings(): Promise { diff --git a/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-service.ts b/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-service.ts index 363a852c..ccba33c0 100644 --- a/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-service.ts +++ b/frontend/app/src/app/modules/data/types/place/special/mensa/place-mensa-service.ts @@ -75,7 +75,7 @@ export class PlaceMensaService { sort: [ { arguments: { - field: `offers.prices.${(priceGroup.value as string).replace(/s$/, '')}`, + field: `offers.prices.${(priceGroup as string).replace(/s$/, '')}`, }, order: 'desc', type: 'generic', diff --git a/frontend/app/src/app/modules/favorites/favorites-page.component.ts b/frontend/app/src/app/modules/favorites/favorites-page.component.ts index 167f863a..37bb7a06 100644 --- a/frontend/app/src/app/modules/favorites/favorites-page.component.ts +++ b/frontend/app/src/app/modules/favorites/favorites-page.component.ts @@ -12,21 +12,13 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, OnInit} from '@angular/core'; -import {AlertController, AnimationController} from '@ionic/angular'; -import {ActivatedRoute, Router} from '@angular/router'; -import {NGXLogger} from 'ngx-logger'; +import {Component, inject, OnInit} from '@angular/core'; import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators'; import {combineLatest} from 'rxjs'; import {SCThingType} from '@openstapps/core'; import {FavoritesService} from './favorites.service'; -import {DataRoutingService} from '../data/data-routing.service'; import {ContextMenuService} from '../menu/context/context-menu.service'; import {SearchPageComponent} from '../data/list/search-page.component'; -import {DataProvider} from '../data/data.provider'; -import {SettingsProvider} from '../settings/settings.provider'; -import {PositionService} from '../map/position.service'; -import {ConfigProvider} from '../config/config.provider'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; /** @@ -42,34 +34,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni showNavigation = false; - constructor( - alertController: AlertController, - dataProvider: DataProvider, - contextMenuService: ContextMenuService, - settingsProvider: SettingsProvider, - logger: NGXLogger, - dataRoutingService: DataRoutingService, - router: Router, - route: ActivatedRoute, - positionService: PositionService, - private favoritesService: FavoritesService, - configProvider: ConfigProvider, - animationController: AnimationController, - ) { - super( - alertController, - dataProvider, - contextMenuService, - settingsProvider, - logger, - dataRoutingService, - router, - route, - positionService, - configProvider, - animationController, - ); - } + private favoritesService = inject(FavoritesService); ngOnInit() { super.ngOnInit(false); @@ -96,16 +61,6 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni this.queryChanged.next(); } }); - this.settingsProvider.settingsActionChanged$ - .pipe(takeUntilDestroyed(this.destroy$)) - .subscribe(({type, payload}) => { - if (type === 'stapps.settings.changed') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {category, name, value} = payload!; - this.logger.log(`received event "settings.changed" with category: - ${category}, name: ${name}, value: ${JSON.stringify(value)}`); - } - }); this.dataRoutingService .itemSelectListener() .pipe(takeUntilDestroyed(this.destroy$)) diff --git a/frontend/app/src/app/modules/favorites/favorites.service.ts b/frontend/app/src/app/modules/favorites/favorites.service.ts index 9c8cf121..d9179fab 100644 --- a/frontend/app/src/app/modules/favorites/favorites.service.ts +++ b/frontend/app/src/app/modules/favorites/favorites.service.ts @@ -28,7 +28,6 @@ import { } from '@openstapps/core'; import {StorageProvider} from '../storage/storage.provider'; import {DataProvider} from '../data/data.provider'; -import {ThingTranslatePipe} from '../../translation/thing-translate.pipe'; import {TranslateService} from '@ngx-translate/core'; import {ThingTranslateService} from '../../translation/thing-translate.service'; import {BehaviorSubject, Observable} from 'rxjs'; @@ -41,11 +40,6 @@ import {debounceTime, map} from 'rxjs/operators'; providedIn: 'root', }) export class FavoritesService { - /** - * Translation pipe - */ - thingTranslatePipe: ThingTranslatePipe; - favorites = new BehaviorSubject>(new Map()); // using debounce time 0 allows change detection to run through async suspension @@ -93,8 +87,8 @@ export class FavoritesService { return items.sort((a, b) => { return ( new Intl.Collator(this.translate.currentLang).compare( - this.thingTranslatePipe.transform(field, a), - this.thingTranslatePipe.transform(field, b), + this.thingTranslate.get(a, field) as string, + this.thingTranslate.get(b, field) as string, ) * reverse ); }); @@ -124,7 +118,6 @@ export class FavoritesService { private readonly translate: TranslateService, private readonly thingTranslate: ThingTranslateService, ) { - this.thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate); void this.emitAll(); } @@ -185,7 +178,9 @@ export class FavoritesService { const textFilteredItems: SCIndexableThings[] = []; for (const item of items) { if ( - this.thingTranslatePipe.transform('name', item).toLowerCase().includes(queryText.toLowerCase()) + (this.thingTranslate.get(item, 'name') as string) + .toLowerCase() + .includes(queryText.toLowerCase()) ) { textFilteredItems.push(item); } diff --git a/frontend/app/src/app/modules/hebis/daia-data.provider.ts b/frontend/app/src/app/modules/hebis/daia-data.provider.ts index 860ed3a6..632b6bf6 100644 --- a/frontend/app/src/app/modules/hebis/daia-data.provider.ts +++ b/frontend/app/src/app/modules/hebis/daia-data.provider.ts @@ -17,7 +17,6 @@ import {DaiaAvailabilityResponse, DaiaHolding, DaiaService} from './protocol/res import {StorageProvider} from '../storage/storage.provider'; import {HttpClient, HttpHeaders} from '@angular/common/http'; import {ConfigProvider} from '../config/config.provider'; -import {SCFeatureConfiguration} from '@openstapps/core'; import {NGXLogger} from 'ngx-logger'; import {TranslateService} from '@ngx-translate/core'; @@ -67,7 +66,7 @@ export class DaiaDataProvider { async getAvailability(id: string): Promise { if (this.daiaServiceUrl === undefined) { try { - const features = this.configProvider.getValue('features') as SCFeatureConfiguration; + const features = this.configProvider.config.app.features; if (features.extern?.daia?.url) { this.daiaServiceUrl = features.extern?.daia?.url; } else { diff --git a/frontend/app/src/app/modules/hebis/list/hebis-search-page.component.ts b/frontend/app/src/app/modules/hebis/list/hebis-search-page.component.ts index fcdfd233..863cff35 100644 --- a/frontend/app/src/app/modules/hebis/list/hebis-search-page.component.ts +++ b/frontend/app/src/app/modules/hebis/list/hebis-search-page.component.ts @@ -114,16 +114,6 @@ export class HebisSearchPageComponent extends SearchPageComponent implements OnI this.queryChanged.next(); } }); - this.settingsProvider.settingsActionChanged$ - .pipe(takeUntilDestroyed(this.destroy$)) - .subscribe(({type, payload}) => { - if (type === 'stapps.settings.changed') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {category, name, value} = payload!; - this.logger.log(`received event "settings.changed" with category: - ${category}, name: ${name}, value: ${JSON.stringify(value)}`); - } - }); this.dataRoutingService .itemSelectListener() .pipe(takeUntilDestroyed(this.destroy$)) diff --git a/frontend/app/src/app/modules/library/account/library-account.service.ts b/frontend/app/src/app/modules/library/account/library-account.service.ts index f3d81d14..4c1d8fcd 100644 --- a/frontend/app/src/app/modules/library/account/library-account.service.ts +++ b/frontend/app/src/app/modules/library/account/library-account.service.ts @@ -12,14 +12,9 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {Injectable} from '@angular/core'; import {JQueryRequestor, Requestor} from '@openid/appauth'; -import { - SCAuthorizationProviderType, - SCFeatureConfiguration, - SCFeatureConfigurationExtern, -} from '@openstapps/core'; +import {SCAuthorizationProviderType, SCFeatureConfigurationExtern} from '@openstapps/core'; import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types'; import {HebisDataProvider} from '../../hebis/hebis-data.provider'; import {PAIATokenResponse} from '../../auth/paia/paia-token-response'; @@ -53,9 +48,7 @@ export class LibraryAccountService { private readonly toastController: ToastController, ) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const config: SCFeatureConfigurationExtern = ( - configProvider.getValue('features') as SCFeatureConfiguration - ).extern!.paia; + const config: SCFeatureConfigurationExtern = configProvider.config.app.features.extern!.paia; this.baseUrl = config.url; this.authType = config.authProvider as SCAuthorizationProviderType; } diff --git a/frontend/app/src/app/modules/map/map.module.ts b/frontend/app/src/app/modules/map/map.module.ts index 6d631630..f8e856c4 100644 --- a/frontend/app/src/app/modules/map/map.module.ts +++ b/frontend/app/src/app/modules/map/map.module.ts @@ -19,7 +19,6 @@ import {LeafletModule} from '@asymmetrik/ngx-leaflet'; import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster'; import {IonicModule} from '@ionic/angular'; import {TranslateModule} from '@ngx-translate/core'; -import {Polygon} from 'geojson'; import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ConfigProvider} from '../config/config.provider'; import {DataFacetsProvider} from '../data/data-facets.provider'; @@ -42,7 +41,7 @@ import {GeoNavigationDirective} from './geo-navigation.directive'; */ export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) { return async () => { - mapProvider.defaultPolygon = (await configProvider.getValue('campusPolygon')) as Polygon; + mapProvider.defaultPolygon = configProvider.config.app.campusPolygon; }; } diff --git a/frontend/app/src/app/modules/map/map.provider.ts b/frontend/app/src/app/modules/map/map.provider.ts index ee488dce..3d0a22d1 100644 --- a/frontend/app/src/app/modules/map/map.provider.ts +++ b/frontend/app/src/app/modules/map/map.provider.ts @@ -116,7 +116,7 @@ export class MapProvider { private positionService: PositionService, private configProvider: ConfigProvider, ) { - this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon; + this.defaultPolygon = this.configProvider.config.app.campusPolygon; } /** diff --git a/frontend/app/src/app/modules/menu/context/context-menu.component.ts b/frontend/app/src/app/modules/menu/context/context-menu.component.ts index 0078a894..e800a3a6 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.component.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu.component.ts @@ -13,11 +13,11 @@ * this program. If not, see . */ import {Component, Input} from '@angular/core'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core'; +import {SCThingType} from '@openstapps/core'; import {ContextMenuService} from './context-menu.service'; import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ThingTranslateService} from '../../../translation/thing-translate.service'; /** * The context menu @@ -59,11 +59,6 @@ export class ContextMenuComponent { return this.filterOption.options.filter(it => it.buckets.length > 0); } - /** - * Possible languages to be used for translation - */ - language: keyof SCTranslations; - /** * Mapping of SCThingType */ @@ -74,22 +69,10 @@ export class ContextMenuComponent { */ sortOption: SortContext; - /** - * Core translator - */ - translator: SCThingTranslator; - constructor( - private translateService: TranslateService, private readonly contextMenuService: ContextMenuService, + private readonly thingTranslateService: ThingTranslateService, ) { - this.language = this.translateService.currentLang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - - this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => { - this.language = event.lang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - }); this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => { this.filterOption = filterContext; }); @@ -109,7 +92,7 @@ export class ContextMenuComponent { * Returns translated property value */ getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { - return this.translator.translatedPropertyValue(onlyForType, field, key); + return this.thingTranslateService.translator.translatedPropertyValue(onlyForType, field, key); } /** diff --git a/frontend/app/src/app/modules/menu/navigation/navigation.component.ts b/frontend/app/src/app/modules/menu/navigation/navigation.component.ts index e10a7c83..fe904ee9 100644 --- a/frontend/app/src/app/modules/menu/navigation/navigation.component.ts +++ b/frontend/app/src/app/modules/menu/navigation/navigation.component.ts @@ -13,74 +13,23 @@ * this program. If not, see . */ import {Component, OnInit} from '@angular/core'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import { - SCAppConfigurationMenuCategory, - SCLanguage, - SCThingTranslator, - SCTranslations, -} from '@openstapps/core'; -import {NavigationService} from './navigation.service'; -import config from 'capacitor.config'; -import {SettingsProvider} from '../../settings/settings.provider'; -import {BreakpointObserver} from '@angular/cdk/layout'; +import {SCAppConfigurationMenuCategory} from '@openstapps/core'; +import {ConfigProvider} from '../../config/config.provider'; -/** - * Generated class for the MenuPage page. - * - * See https://ionicframework.com/docs/components/#navigation for more info on - * Ionic pages and navigation. - */ @Component({ selector: 'stapps-navigation', styleUrls: ['navigation.scss'], templateUrl: 'navigation.html', }) export class NavigationComponent implements OnInit { - showTabbar = true; - - /** - * Name of the app - */ - appName = config.appName; - - /** - * Possible languages to be used for translation - */ - language: keyof SCTranslations; - /** * Menu entries from config module */ menu: SCAppConfigurationMenuCategory[]; - /** - * Core translator - */ - translator: SCThingTranslator; - - constructor( - public translateService: TranslateService, - private navigationService: NavigationService, - private settingsProvider: SettingsProvider, - private responsive: BreakpointObserver, - ) { - translateService.onLangChange.subscribe((event: LangChangeEvent) => { - this.language = event.lang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - }); - - this.responsive.observe(['(min-width: 768px)']).subscribe(result => { - this.showTabbar = !result.matches; - }); - } + constructor(private config: ConfigProvider) {} async ngOnInit() { - this.language = (await this.settingsProvider.getValue( - 'profile', - 'language', - )) as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - this.menu = await this.navigationService.getMenu(); + this.menu = this.config.config.app.menus; } } diff --git a/frontend/app/src/app/modules/menu/navigation/navigation.html b/frontend/app/src/app/modules/menu/navigation/navigation.html index 013feb8b..a05d009a 100644 --- a/frontend/app/src/app/modules/menu/navigation/navigation.html +++ b/frontend/app/src/app/modules/menu/navigation/navigation.html @@ -34,11 +34,11 @@ class="menu-category" > - {{ category.translations[language]?.title | titlecase }} + {{ 'title' | translateSimple: category | titlecase }} - {{ item.translations[language]?.title | titlecase }} + {{ 'title' | translateSimple: item | titlecase }} diff --git a/frontend/app/src/app/modules/menu/navigation/navigation.module.ts b/frontend/app/src/app/modules/menu/navigation/navigation.module.ts index 53879654..9bab2bf9 100644 --- a/frontend/app/src/app/modules/menu/navigation/navigation.module.ts +++ b/frontend/app/src/app/modules/menu/navigation/navigation.module.ts @@ -22,10 +22,11 @@ import {IonIconModule} from '../../../util/ion-icon/ion-icon.module'; import {TranslateModule} from '@ngx-translate/core'; import {RouterModule} from '@angular/router'; import {OfflineNoticeComponent} from './offline-notice.component'; +import {ThingTranslateModule} from '../../../translation/thing-translate.module'; @NgModule({ declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent], - imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule], + imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule, ThingTranslateModule], exports: [TabsComponent, RootLinkDirective, NavigationComponent], }) export class NavigationModule {} diff --git a/frontend/app/src/app/modules/menu/navigation/navigation.scss b/frontend/app/src/app/modules/menu/navigation/navigation.scss index 172bdd9e..b5904cb0 100644 --- a/frontend/app/src/app/modules/menu/navigation/navigation.scss +++ b/frontend/app/src/app/modules/menu/navigation/navigation.scss @@ -60,6 +60,7 @@ stapps-navigation-tabs { } } +stapps-offline-notice.needs-reload ~ ion-split-pane, stapps-offline-notice.has-error ~ ion-split-pane, stapps-offline-notice.is-offline ~ ion-split-pane { margin-top: calc(var(--font-size-md) + 2 * var(--spacing-sm)); diff --git a/frontend/app/src/app/modules/menu/navigation/navigation.service.ts b/frontend/app/src/app/modules/menu/navigation/navigation.service.ts deleted file mode 100644 index 6dbcc490..00000000 --- a/frontend/app/src/app/modules/menu/navigation/navigation.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (C) 2022 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -import {Injectable} from '@angular/core'; -import {SCAppConfigurationMenuCategory} from '@openstapps/core'; -import {ConfigProvider} from '../../config/config.provider'; -import {NGXLogger} from 'ngx-logger'; - -@Injectable({ - providedIn: 'root', -}) -export class NavigationService { - constructor( - private configProvider: ConfigProvider, - private logger: NGXLogger, - ) {} - - async getMenu() { - let menu: SCAppConfigurationMenuCategory[] = []; - try { - menu = this.configProvider.getValue('menus') as SCAppConfigurationMenuCategory[]; - } catch (error) { - this.logger.error(`error from loading menu entries: ${error}`); - } - - return menu; - } -} diff --git a/frontend/app/src/app/modules/menu/navigation/offline-notice.component.ts b/frontend/app/src/app/modules/menu/navigation/offline-notice.component.ts index a7ff281e..e2f00caa 100644 --- a/frontend/app/src/app/modules/menu/navigation/offline-notice.component.ts +++ b/frontend/app/src/app/modules/menu/navigation/offline-notice.component.ts @@ -17,6 +17,10 @@ import {InternetConnectionService} from '../../../util/internet-connection.servi import {Router} from '@angular/router'; import {NGXLogger} from 'ngx-logger'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {SettingsProvider} from '../../settings/settings.provider'; +import {AnimationController} from '@ionic/angular'; +import {filter, race} from 'rxjs'; +import {ConfigProvider} from '../../config/config.provider'; @Component({ selector: 'stapps-offline-notice', @@ -28,12 +32,17 @@ export class OfflineNoticeComponent { @HostBinding('class.has-error') hasError = false; + @HostBinding('class.needs-reload') needsReload = false; + @ViewChild('spinIcon', {read: ElementRef}) spinIcon: ElementRef; constructor( readonly offlineProvider: InternetConnectionService, readonly router: Router, readonly logger: NGXLogger, + readonly animationCtl: AnimationController, + settingsProvider: SettingsProvider, + configProvider: ConfigProvider, ) { this.offlineProvider.offline$.pipe(takeUntilDestroyed()).subscribe(isOffline => { this.isOffline = isOffline; @@ -41,6 +50,15 @@ export class OfflineNoticeComponent { this.offlineProvider.error$.pipe(takeUntilDestroyed()).subscribe(hasError => { this.hasError = hasError; }); + race( + settingsProvider.needsReload$.pipe(filter(it => it)), + configProvider.needsUpdate$.pipe(filter(it => it)), + ) + .pipe(takeUntilDestroyed()) + .subscribe(() => { + console.log('aha!'); + this.needsReload = true; + }); } retry() { @@ -49,4 +67,15 @@ export class OfflineNoticeComponent { this.spinIcon.nativeElement.classList.add('spin'); this.offlineProvider.retry(); } + + async reloadPage() { + await this.animationCtl + .create() + .duration(100) + .fromTo('opacity', 1, 0) + .addElement(document.querySelector('ion-app')!) + .play(); + + window.location.reload(); + } } diff --git a/frontend/app/src/app/modules/menu/navigation/offline-notice.html b/frontend/app/src/app/modules/menu/navigation/offline-notice.html index f435ad6b..b563bfe7 100644 --- a/frontend/app/src/app/modules/menu/navigation/offline-notice.html +++ b/frontend/app/src/app/modules/menu/navigation/offline-notice.html @@ -20,6 +20,10 @@ {{ 'app.errors.CONNECTION_ERROR' | translate }} + + + {{ 'settings.reloadPage' | translate }} + diff --git a/frontend/app/src/app/modules/menu/navigation/offline-notice.scss b/frontend/app/src/app/modules/menu/navigation/offline-notice.scss index 8bcb267f..2ad37f29 100644 --- a/frontend/app/src/app/modules/menu/navigation/offline-notice.scss +++ b/frontend/app/src/app/modules/menu/navigation/offline-notice.scss @@ -28,6 +28,7 @@ transition: all 150ms ease; + &.needs-reload, &.is-offline, &.has-error { transform: translateY(0); @@ -64,6 +65,7 @@ } } + &.needs-reload > .reload, &.is-offline > .offline-button, &.has-error > .close, &.has-error > .error-button { diff --git a/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts b/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts index 1e04b6ce..c801362e 100644 --- a/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts +++ b/frontend/app/src/app/modules/menu/navigation/tabs-transition.ts @@ -16,6 +16,7 @@ import type {AnimationBuilder} from '@ionic/angular'; import {AnimationController} from '@ionic/angular'; import type {AnimationOptions} from '@ionic/angular/providers/nav-controller'; +import {iosDuration, iosEasing, mdDuration, mdEasing} from '../../../animation/easings'; /** * @@ -23,10 +24,11 @@ import type {AnimationOptions} from '@ionic/angular/providers/nav-controller'; export function tabsTransition(animationController: AnimationController): AnimationBuilder { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (_baseElement: HTMLElement, options: AnimationOptions | any) => { - const duration = options.duration || 350; - const contentExitDuration = options.contentExitDuration || 100; + const isMd = document.querySelector('ion-app.md') !== null; + const duration = isMd ? mdDuration : iosDuration; + const easing = isMd ? mdEasing : iosEasing; - const rootTransition = animationController.create().duration(duration); + const rootTransition = animationController.create().duration(duration).easing(easing); const enterTransition = animationController .create() @@ -39,23 +41,15 @@ export function tabsTransition(animationController: AnimationController): Animat .addElement(options.leavingEl); const exitTransition = animationController .create() - .duration(contentExitDuration * 2) - .easing('cubic-bezier(0.87, 0, 0.13, 1)') .fromTo('opacity', '1', '0') .addElement(options.leavingEl.querySelector('ion-header')); const contentExit = animationController .create() - .easing('linear') - .duration(contentExitDuration) .fromTo('opacity', '1', '0') .addElement(options.leavingEl.querySelectorAll(':scope > *:not(ion-header)')); const contentEnter = animationController .create() - .delay(contentExitDuration) - .duration(duration - contentExitDuration) - .easing('cubic-bezier(0.16, 1, 0.3, 1)') - .fromTo('transform', 'scale(1.025)', 'scale(1)') - .fromTo('opacity', '0', '1') + .fromTo('transform', 'scale(1.05)', 'scale(1)') .addElement(options.enteringEl.querySelectorAll(':scope > *:not(ion-header)')); rootTransition.addAnimation([enterTransition, contentExit, contentEnter, exitTransition, exitZIndex]); diff --git a/frontend/app/src/app/modules/menu/navigation/tabs.component.ts b/frontend/app/src/app/modules/menu/navigation/tabs.component.ts index cea59eb5..2c7e0fcc 100644 --- a/frontend/app/src/app/modules/menu/navigation/tabs.component.ts +++ b/frontend/app/src/app/modules/menu/navigation/tabs.component.ts @@ -12,17 +12,11 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {Component} from '@angular/core'; import {NavigationEnd, Router} from '@angular/router'; -import { - SCAppConfigurationMenuCategory, - SCLanguage, - SCThingTranslator, - SCTranslations, -} from '@openstapps/core'; +import {SCAppConfigurationMenuCategory} from '@openstapps/core'; import {ConfigProvider} from '../../config/config.provider'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; +import {TranslateService} from '@ngx-translate/core'; import {NGXLogger} from 'ngx-logger'; @Component({ @@ -31,21 +25,11 @@ import {NGXLogger} from 'ngx-logger'; styleUrls: ['./tabs.component.scss'], }) export class TabsComponent { - /** - * Possible languages to be used for translation - */ - language: keyof SCTranslations; - /** * Menu entries from config module */ menu: SCAppConfigurationMenuCategory[]; - /** - * Core translator - */ - translator: SCThingTranslator; - /** * Name of selected tab */ @@ -57,8 +41,6 @@ export class TabsComponent { private readonly logger: NGXLogger, private readonly router: Router, ) { - this.language = this.translateService.currentLang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); void this.loadMenuEntries(); this.router.events.subscribe((event: unknown) => { if (event instanceof NavigationEnd) { @@ -66,11 +48,6 @@ export class TabsComponent { } }); this.selectTab(router.url); - - translateService.onLangChange?.subscribe((event: LangChangeEvent) => { - this.language = event.lang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - }); } /** @@ -78,7 +55,7 @@ export class TabsComponent { */ async loadMenuEntries() { try { - const menus = (await this.configProvider.getValue('menus')) as SCAppConfigurationMenuCategory[]; + const menus = this.configProvider.config.app.menus; const menu = menus.slice(0, 5); if (menu) { diff --git a/frontend/app/src/app/modules/menu/navigation/tabs.template.html b/frontend/app/src/app/modules/menu/navigation/tabs.template.html index 4e1a809a..3f5ceffb 100644 --- a/frontend/app/src/app/modules/menu/navigation/tabs.template.html +++ b/frontend/app/src/app/modules/menu/navigation/tabs.template.html @@ -46,6 +46,6 @@ [tab]="category.title" > - {{ category.translations[language]?.title | titlecase }} + {{ 'title' | translateSimple: category | titlecase }} diff --git a/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.html b/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.html deleted file mode 100644 index 92a825dc..00000000 --- a/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.scss b/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.ts b/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.ts deleted file mode 100644 index ec4a46df..00000000 --- a/frontend/app/src/app/modules/news/elements/news-filter-settings/news-settings-filter.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2022 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; -import {newsFilterSettingsFieldsMapping, NewsFilterSettingsNames} from '../../news-filter-settings'; -import {SCSearchValueFilter, SCSetting} from '@openstapps/core'; -import {DataProvider} from '../../../data/data.provider'; - -@Component({ - selector: 'stapps-news-settings-filter', - templateUrl: './news-settings-filter.component.html', - styleUrls: ['./news-settings-filter.component.scss'], -}) -export class NewsSettingsFilterComponent implements OnInit { - /** - * A map of the filters where the keys are settings names - */ - filtersMap = new Map(); - - /** - * Emits the current filters - */ - @Output() filtersChanged = new EventEmitter(); - - /** - * Provided settings to show the filters for - */ - @Input() settings: SCSetting[]; - - ngOnInit() { - for (const setting of this.settings) { - this.filtersMap.set( - setting.name as NewsFilterSettingsNames, - DataProvider.createValueFilter( - newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames], - setting.value as string, - ), - ); - } - - this.filtersChanged.emit([...this.filtersMap.values()]); - } - - /** - * To be executed when a chip filter has been enabled/disabled - * @param setting The value of the filter - */ - stateChanged(setting: SCSetting) { - if (this.filtersMap.get(setting.name as NewsFilterSettingsNames) === undefined) { - this.filtersMap.set( - setting.name as NewsFilterSettingsNames, - DataProvider.createValueFilter( - newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames], - setting.value as string, - ), - ); - } else { - this.filtersMap.delete(setting.name as NewsFilterSettingsNames); - } - - this.filtersChanged.emit([...this.filtersMap.values()]); - } -} diff --git a/frontend/app/src/app/modules/news/news.module.ts b/frontend/app/src/app/modules/news/news.module.ts index 96419e4a..cee8ffe6 100644 --- a/frontend/app/src/app/modules/news/news.module.ts +++ b/frontend/app/src/app/modules/news/news.module.ts @@ -20,13 +20,11 @@ import {TranslateModule} from '@ngx-translate/core'; import {MomentModule} from 'ngx-moment'; import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {DataModule} from '../data/data.module'; -import {SettingsProvider} from '../settings/settings.provider'; import {NewsItemComponent} from './item/news-item.component'; import {NewsPageComponent} from './page/news-page.component'; import {SkeletonNewsItemComponent} from './item/skeleton-news-item.component'; import {ChipFilterComponent} from '../data/chips/filter/chip-filter.component'; import {SettingsModule} from '../settings/settings.module'; -import {NewsSettingsFilterComponent} from './elements/news-filter-settings/news-settings-filter.component'; import {UtilModule} from '../../util/util.module'; import {IonIconModule} from '../../util/ion-icon/ion-icon.module'; @@ -36,13 +34,7 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}]; * News Module */ @NgModule({ - declarations: [ - NewsPageComponent, - SkeletonNewsItemComponent, - NewsItemComponent, - ChipFilterComponent, - NewsSettingsFilterComponent, - ], + declarations: [NewsPageComponent, SkeletonNewsItemComponent, NewsItemComponent, ChipFilterComponent], imports: [ IonicModule.forRoot(), ThingTranslateModule.forChild(), @@ -56,7 +48,6 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}]; SettingsModule, UtilModule, ], - providers: [SettingsProvider], exports: [NewsItemComponent], }) export class NewsModule {} diff --git a/frontend/app/src/app/modules/news/news.provider.ts b/frontend/app/src/app/modules/news/news.provider.ts index dfe970e2..2f3edaf2 100644 --- a/frontend/app/src/app/modules/news/news.provider.ts +++ b/frontend/app/src/app/modules/news/news.provider.ts @@ -19,17 +19,18 @@ import { SCSearchBooleanFilter, SCSearchFilter, SCSearchQuery, - SCSearchValueFilter, - SCSetting, } from '@openstapps/core'; import {DataProvider} from '../data/data.provider'; -import { - newsFilterSettingsCategory, - newsFilterSettingsFieldsMapping, - NewsFilterSettingsNames, -} from './news-filter-settings'; import {SettingsProvider} from '../settings/settings.provider'; +/** + * The mapping between settings and corresponding data fields for building a value filter + */ +const newsFilterSettingsFieldsMapping = [ + ['language', 'inLanguage'], + ['group', 'audiences'], +] as const; + /** * Service for providing news messages */ @@ -42,28 +43,22 @@ export class NewsProvider { private settingsProvider: SettingsProvider, ) {} - async getCurrentSettings(): Promise { - const settings: SCSetting[] = []; - for (const settingName of Object.keys(newsFilterSettingsFieldsMapping) as NewsFilterSettingsNames[]) { - settings.push(await this.settingsProvider.getSetting(newsFilterSettingsCategory, settingName)); - } - return settings; - } - + /** + * Gets the news filter based on user group and language settings + */ async getCurrentFilters(): Promise { - const settings = await this.getCurrentSettings(); - const filtersMap = new Map(); - for (const setting of settings) { - filtersMap.set( - setting.name as NewsFilterSettingsNames, - DataProvider.createValueFilter( - newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames], - setting.value as string, - ), - ); - } - - return [...filtersMap.values()]; + return Promise.all( + newsFilterSettingsFieldsMapping.map( + async ([setting, field]) => + ({ + type: 'value', + arguments: { + field, + value: await this.settingsProvider.getSetting('profile', setting), + }, + }) satisfies SCSearchFilter, + ), + ); } /** diff --git a/frontend/app/src/app/modules/news/page/news-page.component.ts b/frontend/app/src/app/modules/news/page/news-page.component.ts index 449368c6..eae16b61 100644 --- a/frontend/app/src/app/modules/news/page/news-page.component.ts +++ b/frontend/app/src/app/modules/news/page/news-page.component.ts @@ -12,9 +12,9 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, OnInit} from '@angular/core'; +import {Component} from '@angular/core'; import {IonRefresher} from '@ionic/angular'; -import {SCMessage, SCSearchFilter, SCSearchValueFilter, SCSetting} from '@openstapps/core'; +import {SCMessage} from '@openstapps/core'; import {NewsProvider} from '../news.provider'; /** @@ -25,7 +25,7 @@ import {NewsProvider} from '../news.provider'; templateUrl: 'news-page.html', styleUrls: ['news-page.scss'], }) -export class NewsPageComponent implements OnInit { +export class NewsPageComponent { /** * Thing counter to start query the next page from */ @@ -51,24 +51,20 @@ export class NewsPageComponent implements OnInit { */ elementSize = [300, 300]; - /** - * Relevant settings - */ - settings: SCSetting[]; - - /** - * Active filters - */ - filters: SCSearchFilter[]; - - constructor(private newsProvider: NewsProvider) {} + constructor(private newsProvider: NewsProvider) { + this.fetchNews(); + } /** * Fetch news from the backend */ async fetchNews() { this.from = this.pageSize; - this.news = await this.newsProvider.getList(this.pageSize, 0, [...this.filters]); + this.news = await this.newsProvider.getList( + this.pageSize, + 0, + await this.newsProvider.getCurrentFilters(), + ); } /** @@ -77,7 +73,11 @@ export class NewsPageComponent implements OnInit { async loadMore(infiniteScrollElement?: HTMLIonInfiniteScrollElement, more = this.pageSize): Promise { const from = this.from; this.from += more; - const fetchedNews = await this.newsProvider.getList(more, from, [...this.filters]); + const fetchedNews = await this.newsProvider.getList( + more, + from, + await this.newsProvider.getCurrentFilters(), + ); this.news = [...this.news, ...fetchedNews]; await infiniteScrollElement?.complete(); @@ -96,13 +96,6 @@ export class NewsPageComponent implements OnInit { } } - /** - * Initialize the local variables on component initialization - */ - async ngOnInit() { - this.settings = await this.newsProvider.getCurrentSettings(); - } - /** * Updates the shown list * @param refresher Refresher component that triggers the update @@ -116,13 +109,4 @@ export class NewsPageComponent implements OnInit { await refresher.complete(); } } - - /** - * Executed when filters have been changed - * @param filters Current filters to be used - */ - toggleFilter(filters: SCSearchValueFilter[]) { - this.filters = filters; - void this.fetchNews(); - } } diff --git a/frontend/app/src/app/modules/news/page/news-page.html b/frontend/app/src/app/modules/news/page/news-page.html index 752ab70a..2a68b189 100644 --- a/frontend/app/src/app/modules/news/page/news-page.html +++ b/frontend/app/src/app/modules/news/page/news-page.html @@ -32,17 +32,6 @@ > - - - - - - -
diff --git a/frontend/app/src/app/modules/settings/item/settings-item.component.ts b/frontend/app/src/app/modules/settings/item/settings-item.component.ts index 68e741cf..a4128dcd 100644 --- a/frontend/app/src/app/modules/settings/item/settings-item.component.ts +++ b/frontend/app/src/app/modules/settings/item/settings-item.component.ts @@ -14,8 +14,7 @@ */ import {Component, Input} from '@angular/core'; import {AlertController} from '@ionic/angular'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import {SCLanguageCode, SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core'; +import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core'; import {SettingsProvider} from '../settings.provider'; /** @@ -42,23 +41,10 @@ export class SettingsItemComponent { */ @Input() setting: SCSetting; - /** - * - * @param alertCtrl AlertController - * @param translateService TranslateService - * @param settingsProvider SettingProvider - */ constructor( private readonly alertCtrl: AlertController, - private readonly translateService: TranslateService, private readonly settingsProvider: SettingsProvider, - ) { - translateService.onLangChange.subscribe((_event: LangChangeEvent) => { - this.isVisible = false; - // TODO: Issue #53 check workaround for selected 'select option' not updating translation - setTimeout(() => (this.isVisible = true)); - }); - } + ) {} /** * Shows alert with given title and message and a 'ok' button @@ -82,14 +68,6 @@ export class SettingsItemComponent { this.setting.value !== undefined && SettingsProvider.validateValue(this.setting, this.setting.value) ) { - // handle general settings, with special actions - switch (this.setting.name) { - case 'language': { - this.translateService.use(this.setting.value as SCLanguageCode); - break; - } - default: - } await this.settingsProvider.setSettingValue( this.setting.categories[0], this.setting.name, @@ -97,7 +75,7 @@ export class SettingsItemComponent { ); } else { // reset setting - this.setting.value = (await this.settingsProvider.getValue( + this.setting.value = (await this.settingsProvider.getSetting( this.setting.categories[0], this.setting.name, )) as SCSettingValue | SCSettingValues; diff --git a/frontend/app/src/app/modules/settings/page/settings-page.component.ts b/frontend/app/src/app/modules/settings/page/settings-page.component.ts index 4745784e..07c7eca8 100644 --- a/frontend/app/src/app/modules/settings/page/settings-page.component.ts +++ b/frontend/app/src/app/modules/settings/page/settings-page.component.ts @@ -47,14 +47,6 @@ export class SettingsPageComponent implements OnInit { */ settingsCache: SettingsCache; - /** - * - * @param alertController AlertController - * @param settingsProvider SettingsProvider - * @param toastController ToastController - * @param translateService TranslateService - * @param changeDetectorRef ChangeDetectorRef - */ constructor( private readonly alertController: AlertController, private readonly settingsProvider: SettingsProvider, diff --git a/frontend/app/src/app/modules/settings/page/settings-page.html b/frontend/app/src/app/modules/settings/page/settings-page.html index 571b3428..383bad64 100644 --- a/frontend/app/src/app/modules/settings/page/settings-page.html +++ b/frontend/app/src/app/modules/settings/page/settings-page.html @@ -26,20 +26,6 @@
- - + {{ 'settings.resetSettings' | translate }} diff --git a/frontend/app/src/app/modules/settings/setting-translate.pipe.ts b/frontend/app/src/app/modules/settings/setting-translate.pipe.ts index da6be7db..1a609cdf 100644 --- a/frontend/app/src/app/modules/settings/setting-translate.pipe.ts +++ b/frontend/app/src/app/modules/settings/setting-translate.pipe.ts @@ -12,11 +12,8 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {Pipe, PipeTransform} from '@angular/core'; -import {TranslateService} from '@ngx-translate/core'; import {SCSetting} from '@openstapps/core'; -import {ThingTranslatePipe} from '../../translation/thing-translate.pipe'; import {ThingTranslateService} from '../../translation/thing-translate.service'; /** @@ -27,14 +24,10 @@ import {ThingTranslateService} from '../../translation/thing-translate.service'; pure: true, }) export class SettingTranslatePipe implements PipeTransform { - constructor( - private readonly translate: TranslateService, - private readonly thingTranslate: ThingTranslateService, - ) {} + constructor(private readonly thingTranslate: ThingTranslateService) {} - transform(setting: SCSetting): string | undefined { - const thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate); - const translatedSettingValues = thingTranslatePipe.transform('values', setting); + transform(setting: SCSetting) { + const translatedSettingValues = this.thingTranslate.get(setting, 'values') as string; return translatedSettingValues ? String(translatedSettingValues[setting.values?.indexOf(setting.value as string) as number]) diff --git a/frontend/app/src/app/modules/settings/settings.module.ts b/frontend/app/src/app/modules/settings/settings.module.ts index 911f4978..fd2f03c4 100644 --- a/frontend/app/src/app/modules/settings/settings.module.ts +++ b/frontend/app/src/app/modules/settings/settings.module.ts @@ -18,13 +18,10 @@ import {FormsModule} from '@angular/forms'; import {RouterModule, Routes} from '@angular/router'; import {IonicModule} from '@ionic/angular'; import {TranslateModule} from '@ngx-translate/core'; - import {ThingTranslateModule} from '../../translation/thing-translate.module'; -import {ConfigProvider} from '../config/config.provider'; import {SettingsItemComponent} from './item/settings-item.component'; import {SettingsPageComponent} from './page/settings-page.component'; import {SettingTranslatePipe} from './setting-translate.pipe'; -import {SettingsProvider} from './settings.provider'; import {CalendarSyncSettingsComponent} from './page/calendar-sync-settings.component'; import {ScheduleProvider} from '../calendar/schedule.provider'; import {ThingTranslatePipe} from '../../translation/thing-translate.pipe'; @@ -60,13 +57,6 @@ const settingsRoutes: Routes = [{path: 'settings', component: SettingsPageCompon RouterModule.forChild(settingsRoutes), UtilModule, ], - providers: [ - ScheduleSyncService, - ConfigProvider, - SettingsProvider, - CalendarService, - ScheduleProvider, - ThingTranslatePipe, - ], + providers: [ScheduleSyncService, CalendarService, ScheduleProvider, ThingTranslatePipe], }) export class SettingsModule {} diff --git a/frontend/app/src/app/modules/settings/settings.provider.ts b/frontend/app/src/app/modules/settings/settings.provider.ts index 0cb9c75e..7fda9038 100644 --- a/frontend/app/src/app/modules/settings/settings.provider.ts +++ b/frontend/app/src/app/modules/settings/settings.provider.ts @@ -15,7 +15,7 @@ import {Injectable} from '@angular/core'; import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core'; import deepMerge from 'deepmerge'; -import {Subject} from 'rxjs'; +import {BehaviorSubject, Subject} from 'rxjs'; import {ConfigProvider} from '../config/config.provider'; import {StorageProvider} from '../storage/storage.provider'; @@ -89,7 +89,9 @@ export interface SettingsAction { /** * Provider for app settings */ -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class SettingsProvider { /** * Source of settings actions @@ -103,16 +105,16 @@ export class SettingsProvider { */ categoriesOrder: string[]; - /** - * Settings actions observable - */ - settingsActionChanged$ = this.settingsActionSource.asObservable(); - /** * Cache for the imported settings */ settingsCache: SettingsCache; + /** + * Whether the page needs a reload + */ + needsReload$ = new BehaviorSubject(false); + /** * Return true if all given values are valid to possible values in given settingInput * @param possibleValues Possible values @@ -148,9 +150,7 @@ export class SettingsProvider { return false; } - return ( - possibleValues !== undefined && Array.isArray(possibleValues) && possibleValues.includes(enteredValue) - ); + return Array.isArray(possibleValues) && possibleValues.includes(enteredValue); } /** @@ -206,7 +206,7 @@ export class SettingsProvider { } /** - * Add an Setting to the Cache if not exist and set undefined value to defaultValue + * Add a Setting to the Cache if not exist and set undefined value to defaultValue * @param setting Setting with categories, defaultValue, name, input type and valid values */ private addSetting(setting: SCSetting): void { @@ -281,34 +281,20 @@ export class SettingsProvider { return this.categoriesOrder; } - /** - * Returns copy of a setting if exist - * @param category the category of requested setting - * @param name the name of requested setting - * @throws Exception if setting is not provided - */ - public async getSetting(category: string, name: string): Promise { - await this.init(); - if (this.settingExists(category, name)) { - // return a copy of the settings - return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name])); - } - throw new Error(`Setting "${name}" not provided`); - } - /** * Returns copy of a settings value if exist * @param category the category of requested setting * @param name the name of requested setting * @throws Exception if setting is not provided */ - public async getValue(category: string, name: string): Promise { - await this.init(); - if (this.settingExists(category, name)) { - // return a copy of the settings value - return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name].value)); - } - throw new Error(`Setting "${name}" not provided`); + public async getSetting( + category: 'profile' | string, + name: string, + ): Promise { + const settings = await this.storage.get(STORAGE_KEY_SETTING_VALUES); + const value = settings[category]?.[name]; + if (!value) throw new Error(`Setting "${name}" not provided`); + return value as T; } /** @@ -319,8 +305,8 @@ export class SettingsProvider { this.needsInit = false; try { - const settings: SCSetting[] = this.configProvider.getValue('settings') as SCSetting[]; - for (const setting of settings) this.addSetting(setting); + const settings: SCSetting[] = this.configProvider.config.app.settings; + for (const setting of settings) this.addSetting(JSON.parse(JSON.stringify(setting))); for (const category of Object.keys(this.settingsCache)) { if (!this.categoriesOrder.includes(category)) { @@ -347,7 +333,6 @@ export class SettingsProvider { : this.settingsCache[categoryKey].settings[settingKey].defaultValue; } } - await this.saveSettingValues(); } } @@ -397,6 +382,9 @@ export class SettingsProvider { this.getSettingValuesFromCache(), ); } + + this.needsReload$.next(true); + this.needsReload$.complete(); } /** diff --git a/frontend/app/src/app/modules/config/config.module.ts b/frontend/app/src/app/translation/collections/entries.pipe.ts similarity index 62% rename from frontend/app/src/app/modules/config/config.module.ts rename to frontend/app/src/app/translation/collections/entries.pipe.ts index cd3ee9cb..15a80c14 100644 --- a/frontend/app/src/app/modules/config/config.module.ts +++ b/frontend/app/src/app/translation/collections/entries.pipe.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 StApps + * Copyright (C) 2023 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -12,16 +12,15 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {NgModule} from '@angular/core'; -import {DataModule} from '../data/data.module'; -import {StorageModule} from '../storage/storage.module'; -import {ConfigProvider} from './config.provider'; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; -/** - * TODO - */ -@NgModule({ - imports: [StorageModule, DataModule], - providers: [ConfigProvider], +@Injectable() +@Pipe({ + name: 'entries', + pure: true, }) -export class ConfigModule {} +export class EntriesPipe implements PipeTransform { + transform(value: Record): T[] { + return Object.values(value); + } +} diff --git a/frontend/app/src/app/modules/news/news-filter-settings.ts b/frontend/app/src/app/translation/collections/join.pipe.ts similarity index 50% rename from frontend/app/src/app/modules/news/news-filter-settings.ts rename to frontend/app/src/app/translation/collections/join.pipe.ts index 8860344b..59097279 100644 --- a/frontend/app/src/app/modules/news/news-filter-settings.ts +++ b/frontend/app/src/app/translation/collections/join.pipe.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 StApps + * Copyright (C) 2023 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -12,21 +12,23 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {SCSettingCategories, SCThingsField} from '@openstapps/core'; -/** - * Category of settings to use for news filter - */ -export const newsFilterSettingsCategory: SCSettingCategories = 'profile'; -/** - * Settings to use for news filter - */ -export type NewsFilterSettingsNames = 'language' | 'group'; -/** - * The mapping between settings and corresponding data fields for building a value filter - */ -export const newsFilterSettingsFieldsMapping: { - [key in NewsFilterSettingsNames]: SCThingsField; -} = { - language: 'inLanguage', - group: 'audiences', -}; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'join', + pure: true, +}) +export class ArrayJoinPipe implements PipeTransform { + transform(anArray: unknown[] | unknown, separator: string | unknown): string { + if (typeof separator !== 'string' || separator.length <= 0) { + return ''; + } + + if (!Array.isArray(anArray)) { + throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`); + } + + return anArray.join(separator); + } +} diff --git a/frontend/app/src/app/translation/common-string-pipes.ts b/frontend/app/src/app/translation/common-string-pipes.ts deleted file mode 100644 index 49a1f346..00000000 --- a/frontend/app/src/app/translation/common-string-pipes.ts +++ /dev/null @@ -1,416 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import moment from 'moment'; -import {Subscription} from 'rxjs'; -import {logger} from '../_helpers/ts-logger'; - -@Injectable() -@Pipe({ - name: 'join', - pure: true, -}) -export class ArrayJoinPipe implements PipeTransform { - value = ''; - - transform(anArray: unknown[] | unknown, separator: string | unknown): string { - if (typeof separator !== 'string' || separator.length <= 0) { - return this.value; - } - - if (!Array.isArray(anArray)) { - throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`); - } - - this.value = anArray.join(separator); - - return this.value; - } -} - -@Injectable() -@Pipe({ - name: 'entries', - pure: true, -}) -export class EntriesPipe implements PipeTransform { - transform(value: Record): T[] { - return Object.values(value); - } -} - -@Injectable() -@Pipe({ - name: 'toUnix', - pure: true, -}) -export class ToUnixPipe implements PipeTransform { - transform(value: string | number | Date | null | undefined): number { - return (value instanceof Date ? value : new Date(value ?? 0)).valueOf(); - } -} - -@Injectable() -@Pipe({ - name: 'sentencecase', - pure: true, -}) -export class SentenceCasePipe implements PipeTransform { - value = ''; - - transform(aString: string | unknown): string { - if (typeof aString !== 'string') { - throw new SyntaxError( - `Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`, - ); - } - - this.value = aString.slice(0, 1).toUpperCase() + aString.slice(1); - - return this.value; - } -} - -@Injectable() -@Pipe({ - name: 'split', - pure: true, -}) -export class StringSplitPipe implements PipeTransform { - value = new Array(); - - transform(aString: string | unknown, splitter: string | unknown): unknown[] { - if (typeof splitter !== 'string' || splitter.length <= 0) { - return this.value as never; - } - - if (typeof aString !== 'string') { - throw new SyntaxError( - `Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`, - ); - } - - this.value = aString.split(splitter); - - return this.value as never; - } -} -@Injectable() -@Pipe({ - name: 'durationLocalized', - pure: true, -}) -export class DurationLocalizedPipe implements PipeTransform, OnDestroy { - locale: string; - - onLangChange?: Subscription; - - value: string; - - frequencyPrefixes: {[iso6391Code: string]: string} = { - de: 'alle', - en: 'every', - es: 'cada', - pt: 'a cada', - fr: 'tous les', - cn: '每', - ru: 'kаждые', - }; - - constructor(private readonly translate: TranslateService) { - this.locale = translate.currentLang; - } - - private _dispose(): void { - if (this.onLangChange?.closed === false) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); - } - - /** - * @param value An ISO 8601 duration string - * @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency - */ - transform(value: string | unknown, isFrequency = false): string { - this.updateValue(value, isFrequency); - this._dispose(); - if (this.onLangChange?.closed === true) { - this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { - this.locale = event.lang; - this.updateValue(value, isFrequency); - }); - } - - return this.value; - } - - updateValue(value: string | unknown, isFrequency = false): void { - if (typeof value !== 'string') { - logger.warn(`durationLocalized pipe unable to parse input: ${value}`); - - return; - } - - if (isFrequency) { - const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element => - this.locale.includes(element), - ); - this.value = [ - fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en, - moment.duration(value).humanize(), - ].join(' '); - } else { - this.value = moment.duration(value).humanize(); - } - } -} - -@Injectable() -@Pipe({ - name: 'metersLocalized', - pure: false, -}) -export class MetersLocalizedPipe implements PipeTransform, OnDestroy { - locale: string; - - onLangChange?: Subscription; - - value = ''; - - constructor(private readonly translate: TranslateService) { - this.locale = translate.currentLang; - } - - private _dispose(): void { - if (this.onLangChange?.closed === false) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); - } - - transform(value: string | number | unknown): string { - this.updateValue(value); - this._dispose(); - if (this.onLangChange?.closed === true) { - this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { - this.locale = event.lang; - this.updateValue(value); - }); - } - - return this.value; - } - - updateValue(value: string | number | unknown) { - if (typeof value !== 'string' && typeof value !== 'number') { - logger.warn(`metersLocalized pipe unable to parse input: ${value}`); - - return; - } - const imperialLocale = ['US', 'UK', 'LR', 'MM'].some(term => this.locale.includes(term)); - const meters = typeof value === 'string' ? Number.parseFloat(value) : (value as number); - - if (imperialLocale) { - const yards = meters * 1.0936; - const options = { - style: 'unit', - unit: yards >= 1760 ? 'mile' : 'yard', - maximumFractionDigits: yards >= 1760 ? 1 : 0, - } as unknown as Intl.NumberFormatOptions; - this.value = new Intl.NumberFormat(this.locale, options).format(yards >= 1760 ? yards / 1760 : yards); - } else { - const options = { - style: 'unit', - unit: meters >= 1000 ? 'kilometer' : 'meter', - maximumFractionDigits: meters >= 1000 ? 1 : 0, - } as unknown as Intl.NumberFormatOptions; - this.value = new Intl.NumberFormat(this.locale, options).format( - meters >= 1000 ? meters / 1000 : meters, - ); - } - } -} - -@Injectable() -@Pipe({ - name: 'isNaN', - pure: true, -}) -export class IsNaNPipe implements PipeTransform { - transform(value: unknown): boolean { - return Number.isNaN(value); - } -} - -@Injectable() -@Pipe({ - name: 'isNumeric', - pure: true, -}) -export class IsNumericPipe implements PipeTransform { - transform(value: unknown): boolean { - return !Number.isNaN( - typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN, - ); - } -} - -@Injectable() -@Pipe({ - name: 'numberLocalized', - pure: true, -}) -export class NumberLocalizedPipe implements PipeTransform, OnDestroy { - locale: string; - - onLangChange?: Subscription; - - value: string; - - constructor(private readonly translate: TranslateService) { - this.locale = translate.currentLang; - } - - private _dispose(): void { - if (this.onLangChange?.closed === false) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); - } - - /** - * @param value The number to be formatted - * @param formatOptions Formatting options to include. - * As specified by Intl.NumberFormatOptions as comma seperated key:value pairs. - */ - transform(value: string | number | unknown, formatOptions?: string): string { - this.updateValue(value, formatOptions); - this._dispose(); - if (this.onLangChange?.closed === true) { - this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { - this.locale = event.lang; - this.updateValue(value, formatOptions); - }); - } - - return this.value; - } - - updateValue(value: string | number | unknown, formatOptions?: string): void { - if (typeof value !== 'string' && typeof value !== 'number') { - logger.warn(`numberLocalized pipe unable to parse input: ${value}`); - - return; - } - const options = formatOptions - ?.split(',') - .map(element => element.split(':')) - .reduce( - (accumulator, [key, value_]) => ({ - ...accumulator, - [key.trim()]: value_.trim(), - }), - {}, - ) as Intl.NumberFormatOptions; - const float = typeof value === 'string' ? Number.parseFloat(value) : (value as number); - this.value = new Intl.NumberFormat(this.locale, options).format(float); - } -} - -@Injectable() -@Pipe({ - name: 'dateFormat', - pure: true, -}) -export class DateLocalizedFormatPipe implements PipeTransform, OnDestroy { - locale: string; - - onLangChange?: Subscription; - - value: string; - - constructor(private readonly translate: TranslateService) { - this.locale = translate.currentLang; - } - - private _dispose(): void { - if (this.onLangChange?.closed === false) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); - } - - /** - * @param value The date to be formatted - * @param formatOptions Dateformat options to include. - * As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs - * Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35" - */ - transform(value: string | unknown, formatOptions?: string): string { - this.updateValue(value, formatOptions); - this._dispose(); - if (this.onLangChange?.closed === true) { - this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => { - this.locale = event.lang; - this.updateValue(value, formatOptions); - }); - } - - return this.value; - } - - updateValue(value: string | Date | unknown, formatOptions?: string): void { - if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') { - logger.warn(`dateFormat pipe unable to parse input: ${value}`); - - return; - } - const options = formatOptions - ?.split(',') - .map(element => element.split(':')) - .reduce( - (accumulator, [key, value_]) => ({ - ...accumulator, - [key.trim()]: value_.trim(), - }), - {}, - ) as Intl.DateTimeFormatOptions; - const date = typeof value === 'string' ? Date.parse(value) : (value as Date); - this.value = new Intl.DateTimeFormat( - this.locale, - options ?? { - day: 'numeric', - month: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - }, - ).format(date); - } -} diff --git a/frontend/app/src/app/translation/date-time/date-format.pipe.ts b/frontend/app/src/app/translation/date-time/date-format.pipe.ts new file mode 100644 index 00000000..a0200a83 --- /dev/null +++ b/frontend/app/src/app/translation/date-time/date-format.pipe.ts @@ -0,0 +1,47 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; +import {TranslateService} from '@ngx-translate/core'; +import {logger} from '../../_helpers/ts-logger'; + +@Injectable() +@Pipe({ + name: 'dateFormat', + pure: true, +}) +export class DateLocalizedFormatPipe implements PipeTransform { + constructor(private readonly translate: TranslateService) {} + + /** + * @param value The date to be formatted + * @param formatOptions Dateformat options to include. + * As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs + * Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35" + */ + transform(value: string | unknown, formatOptions?: string): string { + if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') { + logger.warn(`dateFormat pipe unable to parse input: ${value}`); + + return ''; + } + const options = formatOptions + ?.split(',') + .map(element => element.split(':')) + .reduce( + (accumulator, [key, value_]) => ({ + ...accumulator, + [key.trim()]: value_.trim(), + }), + {}, + ) as Intl.DateTimeFormatOptions; + const date = typeof value === 'string' ? Date.parse(value) : (value as Date); + return new Intl.DateTimeFormat( + this.translate.currentLang, + options ?? { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + }, + ).format(date); + } +} diff --git a/frontend/app/src/app/translation/date-time/duration-localized.pipe.ts b/frontend/app/src/app/translation/date-time/duration-localized.pipe.ts new file mode 100644 index 00000000..54ac214b --- /dev/null +++ b/frontend/app/src/app/translation/date-time/duration-localized.pipe.ts @@ -0,0 +1,47 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; +import {TranslateService} from '@ngx-translate/core'; +import {logger} from '../../_helpers/ts-logger'; +import moment from 'moment/moment'; + +const frequencyPrefixes: {[iso6391Code: string]: string} = { + de: 'alle', + en: 'every', + es: 'cada', + pt: 'a cada', + fr: 'tous les', + cn: '每', + ru: 'kаждые', +}; + +@Injectable() +@Pipe({ + name: 'durationLocalized', + pure: true, +}) +export class DurationLocalizedPipe implements PipeTransform { + constructor(private readonly translate: TranslateService) {} + + /** + * @param value An ISO 8601 duration string + * @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency + */ + transform(value: string | unknown, isFrequency = false): string { + if (typeof value !== 'string') { + logger.warn(`durationLocalized pipe unable to parse input: ${value}`); + + return ''; + } + + if (isFrequency) { + const fequencyPrefix = Object.keys(frequencyPrefixes).filter(element => + this.translate.currentLang.includes(element), + ); + return [ + fequencyPrefix.length > 0 ? frequencyPrefixes[fequencyPrefix[0]] : frequencyPrefixes.en, + moment.duration(value).humanize(), + ].join(' '); + } else { + return moment.duration(value).humanize(); + } + } +} diff --git a/frontend/app/src/app/translation/date-time/to-unix.pipe.ts b/frontend/app/src/app/translation/date-time/to-unix.pipe.ts new file mode 100644 index 00000000..eb69ea10 --- /dev/null +++ b/frontend/app/src/app/translation/date-time/to-unix.pipe.ts @@ -0,0 +1,12 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'toUnix', + pure: true, +}) +export class ToUnixPipe implements PipeTransform { + transform(value: string | number | Date | null | undefined): number { + return (value instanceof Date ? value : new Date(value ?? 0)).valueOf(); + } +} diff --git a/frontend/app/src/app/translation/numbers/is-nan.pipe.ts b/frontend/app/src/app/translation/numbers/is-nan.pipe.ts new file mode 100644 index 00000000..f5f45cc6 --- /dev/null +++ b/frontend/app/src/app/translation/numbers/is-nan.pipe.ts @@ -0,0 +1,12 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'isNaN', + pure: true, +}) +export class IsNaNPipe implements PipeTransform { + transform(value: unknown): boolean { + return Number.isNaN(value); + } +} diff --git a/frontend/app/src/app/translation/numbers/is-numeric.pipe.ts b/frontend/app/src/app/translation/numbers/is-numeric.pipe.ts new file mode 100644 index 00000000..85178b52 --- /dev/null +++ b/frontend/app/src/app/translation/numbers/is-numeric.pipe.ts @@ -0,0 +1,14 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'isNumeric', + pure: true, +}) +export class IsNumericPipe implements PipeTransform { + transform(value: unknown): boolean { + return !Number.isNaN( + typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN, + ); + } +} diff --git a/frontend/app/src/app/translation/numbers/meters-localized.pipe.ts b/frontend/app/src/app/translation/numbers/meters-localized.pipe.ts new file mode 100644 index 00000000..72ce35a5 --- /dev/null +++ b/frontend/app/src/app/translation/numbers/meters-localized.pipe.ts @@ -0,0 +1,43 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; +import {TranslateService} from '@ngx-translate/core'; +import {logger} from '../../_helpers/ts-logger'; + +@Injectable() +@Pipe({ + name: 'metersLocalized', + pure: true, +}) +export class MetersLocalizedPipe implements PipeTransform { + constructor(private readonly translate: TranslateService) {} + + transform(value: string | number | unknown): string { + if (typeof value !== 'string' && typeof value !== 'number') { + logger.warn(`metersLocalized pipe unable to parse input: ${value}`); + + return ''; + } + const imperialLocale = ['US', 'UK', 'LR', 'MM'].some(term => this.translate.currentLang.includes(term)); + const meters = typeof value === 'string' ? Number.parseFloat(value) : (value as number); + + if (imperialLocale) { + const yards = meters * 1.0936; + const options = { + style: 'unit', + unit: yards >= 1760 ? 'mile' : 'yard', + maximumFractionDigits: yards >= 1760 ? 1 : 0, + } as unknown as Intl.NumberFormatOptions; + return new Intl.NumberFormat(this.translate.currentLang, options).format( + yards >= 1760 ? yards / 1760 : yards, + ); + } else { + const options = { + style: 'unit', + unit: meters >= 1000 ? 'kilometer' : 'meter', + maximumFractionDigits: meters >= 1000 ? 1 : 0, + } as unknown as Intl.NumberFormatOptions; + return new Intl.NumberFormat(this.translate.currentLang, options).format( + meters >= 1000 ? meters / 1000 : meters, + ); + } + } +} diff --git a/frontend/app/src/app/translation/numbers/number-localized.pipe.ts b/frontend/app/src/app/translation/numbers/number-localized.pipe.ts new file mode 100644 index 00000000..180a9c5c --- /dev/null +++ b/frontend/app/src/app/translation/numbers/number-localized.pipe.ts @@ -0,0 +1,37 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; +import {TranslateService} from '@ngx-translate/core'; +import {logger} from '../../_helpers/ts-logger'; + +@Injectable() +@Pipe({ + name: 'numberLocalized', + pure: true, +}) +export class NumberLocalizedPipe implements PipeTransform { + constructor(private readonly translate: TranslateService) {} + + /** + * @param value The number to be formatted + * @param formatOptions Formatting options to include. + * As specified by Intl.NumberFormatOptions as comma seperated key:value pairs. + */ + transform(value: string | number | unknown, formatOptions?: string): string { + if (typeof value !== 'string' && typeof value !== 'number') { + logger.warn(`numberLocalized pipe unable to parse input: ${value}`); + + return ''; + } + const options = formatOptions + ?.split(',') + .map(element => element.split(':')) + .reduce( + (accumulator, [key, value_]) => ({ + ...accumulator, + [key.trim()]: value_.trim(), + }), + {}, + ) as Intl.NumberFormatOptions; + const float = typeof value === 'string' ? Number.parseFloat(value) : (value as number); + return new Intl.NumberFormat(this.translate.currentLang, options).format(float); + } +} diff --git a/frontend/app/src/app/translation/property-name-translate.pipe.ts b/frontend/app/src/app/translation/property-name-translate.pipe.ts index d75876ac..03448bb8 100644 --- a/frontend/app/src/app/translation/property-name-translate.pipe.ts +++ b/frontend/app/src/app/translation/property-name-translate.pipe.ts @@ -12,40 +12,23 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core'; -import {Subscription} from 'rxjs'; -import {TranslateService} from '@ngx-translate/core'; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; import {ThingTranslateService} from './thing-translate.service'; import {isThing, SCThings, SCThingType} from '@openstapps/core'; @Injectable() @Pipe({ name: 'propertyNameTranslate', - pure: false, // required to update the value when the promise is resolved + pure: true, }) -export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy { - value: unknown; - - lastKey?: string; - - lastType: string; - - onLangChange: Subscription; - - constructor( - private readonly translate: TranslateService, - private readonly thingTranslate: ThingTranslateService, - ) {} - - updateValue(key: string, type: string): void { - this.value = this.thingTranslate.getPropertyName(type as SCThingType, key); - } +export class PropertyNameTranslatePipe implements PipeTransform { + constructor(private readonly thingTranslate: ThingTranslateService) {} transform>(query: Q, type: K): string; transform(query: K, thing: T): string; transform(query: unknown, thingOrType: SCThings | string | unknown): unknown { if (typeof query !== 'string' || query.length <= 0) { - return query; + return query as never; } if (!isThing(thingOrType) && typeof thingOrType !== 'string') { @@ -54,37 +37,9 @@ export class PropertyNameTranslatePipe implements PipeTransform, OnDestroy { ); } - // store the params, in case they change - this.lastKey = query; - this.lastType = typeof thingOrType === 'string' ? thingOrType : thingOrType.type; - - this.updateValue(query, this.lastType); - - // if there is a subscription to onLangChange, clean it - this._dispose(); - - if (this.onLangChange?.closed ?? true) { - this.onLangChange = this.translate.onLangChange.subscribe(() => { - if (typeof this.lastKey === 'string') { - this.lastKey = undefined; // we want to make sure it doesn't return the same value until it's been updated - this.updateValue(query, this.lastType); - } - }); - } - - return this.value; - } - - /** - * Clean any existing subscription to change events - */ - private _dispose(): void { - if (this.onLangChange?.closed) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); + return this.thingTranslate.getPropertyName( + isThing(thingOrType) ? thingOrType.type : (thingOrType as SCThingType), + query, + ); } } diff --git a/frontend/app/src/app/translation/strings/sentence-case.pipe.ts b/frontend/app/src/app/translation/strings/sentence-case.pipe.ts new file mode 100644 index 00000000..fce3362d --- /dev/null +++ b/frontend/app/src/app/translation/strings/sentence-case.pipe.ts @@ -0,0 +1,18 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'sentencecase', + pure: true, +}) +export class SentenceCasePipe implements PipeTransform { + transform(aString: string | unknown): string { + if (typeof aString !== 'string') { + throw new SyntaxError( + `Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`, + ); + } + + return aString.slice(0, 1).toUpperCase() + aString.slice(1); + } +} diff --git a/frontend/app/src/app/translation/strings/split.pipe.ts b/frontend/app/src/app/translation/strings/split.pipe.ts new file mode 100644 index 00000000..e5fbb805 --- /dev/null +++ b/frontend/app/src/app/translation/strings/split.pipe.ts @@ -0,0 +1,22 @@ +import {Injectable, Pipe, PipeTransform} from '@angular/core'; + +@Injectable() +@Pipe({ + name: 'split', + pure: true, +}) +export class StringSplitPipe implements PipeTransform { + transform(aString: string | unknown, splitter: string | unknown): unknown[] { + if (typeof splitter !== 'string' || splitter.length <= 0) { + return []; + } + + if (typeof aString !== 'string') { + throw new SyntaxError( + `Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`, + ); + } + + return aString.split(splitter); + } +} diff --git a/frontend/app/src/app/translation/thing-translate.module.ts b/frontend/app/src/app/translation/thing-translate.module.ts index d4421972..235c950a 100644 --- a/frontend/app/src/app/translation/thing-translate.module.ts +++ b/frontend/app/src/app/translation/thing-translate.module.ts @@ -13,25 +13,23 @@ * this program. If not, see . */ import {ModuleWithProviders, NgModule, Provider} from '@angular/core'; -import { - ArrayJoinPipe, - DateLocalizedFormatPipe, - DurationLocalizedPipe, - EntriesPipe, - IsNaNPipe, - IsNumericPipe, - MetersLocalizedPipe, - NumberLocalizedPipe, - SentenceCasePipe, - StringSplitPipe, - ToUnixPipe, -} from './common-string-pipes'; import {ThingTranslateDefaultParser, ThingTranslateParser} from './thing-translate.parser'; import {ThingTranslatePipe} from './thing-translate.pipe'; import {ThingTranslateService} from './thing-translate.service'; import {IonIconModule} from '../util/ion-icon/ion-icon.module'; import {TranslateSimplePipe} from './translate-simple.pipe'; import {PropertyNameTranslatePipe} from './property-name-translate.pipe'; +import {DateLocalizedFormatPipe} from './date-time/date-format.pipe'; +import {NumberLocalizedPipe} from './numbers/number-localized.pipe'; +import {IsNaNPipe} from './numbers/is-nan.pipe'; +import {IsNumericPipe} from './numbers/is-numeric.pipe'; +import {MetersLocalizedPipe} from './numbers/meters-localized.pipe'; +import {DurationLocalizedPipe} from './date-time/duration-localized.pipe'; +import {ToUnixPipe} from './date-time/to-unix.pipe'; +import {ArrayJoinPipe} from './collections/join.pipe'; +import {StringSplitPipe} from './strings/split.pipe'; +import {SentenceCasePipe} from './strings/sentence-case.pipe'; +import {EntriesPipe} from './collections/entries.pipe'; export interface ThingTranslateModuleConfig { parser?: Provider; diff --git a/frontend/app/src/app/translation/thing-translate.parser.ts b/frontend/app/src/app/translation/thing-translate.parser.ts index a2cfb1a4..c05e8871 100644 --- a/frontend/app/src/app/translation/thing-translate.parser.ts +++ b/frontend/app/src/app/translation/thing-translate.parser.ts @@ -13,7 +13,6 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - import {Injectable} from '@angular/core'; /* eslint-disable @typescript-eslint/member-ordering, class-methods-use-this */ @@ -51,10 +50,3 @@ export class ThingTranslateDefaultParser extends ThingTranslateParser { return property; } } - -/** - * TODO - */ -export function isDefined(value?: T | null): value is T { - return value !== undefined && value !== null; -} diff --git a/frontend/app/src/app/translation/thing-translate.pipe.ts b/frontend/app/src/app/translation/thing-translate.pipe.ts index d161f0ce..5b944ef0 100644 --- a/frontend/app/src/app/translation/thing-translate.pipe.ts +++ b/frontend/app/src/app/translation/thing-translate.pipe.ts @@ -12,35 +12,17 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core'; -import {TranslateService} from '@ngx-translate/core'; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; import {isThing, SCThings, SCThingWithoutReferences} from '@openstapps/core'; -import {Subscription} from 'rxjs'; import {ThingTranslateService} from './thing-translate.service'; @Injectable() @Pipe({ name: 'thingTranslate', - pure: false, // required to update the value when the promise is resolved + pure: true, }) -export class ThingTranslatePipe implements PipeTransform, OnDestroy { - value: unknown; - - lastKey?: string; - - lastThing: SCThingWithoutReferences; - - onLangChange: Subscription; - - constructor( - private readonly translate: TranslateService, - // private readonly _ref: ChangeDetectorRef, - private readonly thingTranslate: ThingTranslateService, - ) {} - - updateValue(key: string, thing: SCThingWithoutReferences): void { - this.value = this.thingTranslate.get(thing as SCThings, key); - } +export class ThingTranslatePipe implements PipeTransform { + constructor(private readonly thingTranslate: ThingTranslateService) {} transform( query: P, @@ -56,37 +38,6 @@ export class ThingTranslatePipe implements PipeTransform, OnDestroy { ); } - // store the params, in case they change - this.lastKey = query; - this.lastThing = thing; - - this.updateValue(query, thing); - - // if there is a subscription to onLangChange, clean it - this._dispose(); - - if (this.onLangChange?.closed ?? true) { - this.onLangChange = this.translate.onLangChange.subscribe(() => { - if (typeof this.lastKey === 'string') { - this.lastKey = undefined; // we want to make sure it doesn't return the same value until it's been updated - this.updateValue(query, thing); - } - }); - } - - return this.value as never; - } - - /** - * Clean any existing subscription to change events - */ - private _dispose(): void { - if (this.onLangChange?.closed) { - this.onLangChange?.unsubscribe(); - } - } - - ngOnDestroy(): void { - this._dispose(); + return this.thingTranslate.get(thing as SCThings, query) as never; } } diff --git a/frontend/app/src/app/translation/thing-translate.service.ts b/frontend/app/src/app/translation/thing-translate.service.ts index cf1122c9..5b35467b 100644 --- a/frontend/app/src/app/translation/thing-translate.service.ts +++ b/frontend/app/src/app/translation/thing-translate.service.ts @@ -13,21 +13,9 @@ * this program. If not, see . */ import {Injectable} from '@angular/core'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import { - SCLanguage, - SCLanguageCode, - SCThings, - SCThingTranslator, - SCThingType, - SCTranslations, -} from '@openstapps/core'; -import moment from 'moment'; -import {isDefined, ThingTranslateParser} from './thing-translate.parser'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; -import {setDefaultOptions} from 'date-fns'; -import {DateFnsConfigurationService} from 'ngx-date-fns'; -import {getDateFnsLocale} from './dfns-locale'; +import {TranslateService} from '@ngx-translate/core'; +import {SCLanguageCode, SCThings, SCThingTranslator, SCThingType} from '@openstapps/core'; +import {ThingTranslateParser} from './thing-translate.parser'; // export const DEFAULT_LANGUAGE = new InjectionToken('DEFAULT_LANGUAGE'); @@ -40,28 +28,16 @@ export class ThingTranslateService { translator: SCThingTranslator; /** - * * @param translateService Instance of Angular TranslateService * @param parser An instance of the parser currently used - * @param dfnsConfiguration the date fns configuration */ constructor( - private readonly translateService: TranslateService, + translateService: TranslateService, public parser: ThingTranslateParser, - private dfnsConfiguration: DateFnsConfigurationService, ) { this.translator = new SCThingTranslator( (translateService.currentLang ?? translateService.defaultLang) as SCLanguageCode, ); - /** set the default language from configuration */ - this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => { - this.translator.language = event.lang as keyof SCTranslations; - moment.locale(event.lang); - getDateFnsLocale(event.lang as SCLanguageCode).then(locale => { - setDefaultOptions({locale}); - this.dfnsConfiguration.setLocale(locale); - }); - }); } /** @@ -83,7 +59,7 @@ export class ThingTranslateService { keyPath: string | string[], // eslint-disable-next-line @typescript-eslint/ban-types ): string | number | boolean | object { - if (!isDefined(keyPath) || keyPath.length === 0) { + if (!keyPath || keyPath.length === 0) { throw new Error(`Parameter "keyPath" required`); } if (Array.isArray(keyPath)) { @@ -101,10 +77,10 @@ export class ThingTranslateService { */ public getPropertyName(type: SCThingType, keyPath: string | string[]): string { const translatedPropertyNames = this.translator.translatedPropertyNames(type); - if (!isDefined(translatedPropertyNames)) { + if (!translatedPropertyNames) { throw new Error(`Parameter "type" is an invalid SCThingType`); } - if (!isDefined(keyPath) || keyPath.length === 0) { + if (!keyPath || keyPath.length === 0) { throw new Error(`Parameter "keyPath" required`); } if (Array.isArray(keyPath)) { diff --git a/frontend/app/src/app/translation/translate-simple.pipe.ts b/frontend/app/src/app/translation/translate-simple.pipe.ts index 44da2389..b85bc9c8 100644 --- a/frontend/app/src/app/translation/translate-simple.pipe.ts +++ b/frontend/app/src/app/translation/translate-simple.pipe.ts @@ -12,59 +12,29 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {DestroyRef, inject, Injectable, Pipe, PipeTransform} from '@angular/core'; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; import {TranslateService} from '@ngx-translate/core'; import {get} from '@openstapps/collection-utils'; -import {Subscription} from 'rxjs'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; @Injectable() @Pipe({ name: 'translateSimple', - pure: false, + pure: true, }) export class TranslateSimplePipe implements PipeTransform { - value: unknown; - - query: unknown; - - thing: unknown; - - onLangChange: Subscription; - - destroy$ = inject(DestroyRef); - constructor(private readonly translate: TranslateService) {} - // eslint-disable-next-line @typescript-eslint/ban-types - private updateValue() { - try { - this.value = - get( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this.thing as any).translations[this.translate.currentLang] ?? this.thing, - this.query as string, - ) ?? this.thing; - } catch (error) { - console.warn(`${this.query}: ${error}`); - this.value = this.thing; - } - } - transform(query: P, thing: T): T[P]; transform(query: P, thing: T): P | unknown { - // store the params, in case they change - this.query = query; - this.thing = thing; - - this.updateValue(); - - this.onLangChange ??= this.translate.onLangChange - .pipe(takeUntilDestroyed(this.destroy$)) - .subscribe(() => { - this.updateValue(); - }); - - return this.value as never; + try { + return (get( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (thing as any).translations[this.translate.currentLang] ?? thing, + query as string, + ) ?? thing) as never; + } catch (error) { + console.warn(`${query.toString()}: ${error}`); + return thing as never; + } } } diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json index 214344ad..5c0cdb7b 100644 --- a/frontend/app/src/assets/i18n/de.json +++ b/frontend/app/src/assets/i18n/de.json @@ -550,6 +550,7 @@ "resetToast.message": "Einstellungen wurden zurückgesetzt", "title": "Einstellungen", "resetSettings": "Einstellungen zurücksetzen", + "reloadPage": "Aktualisierung erforderlich", "calendar": { "title": "Kalender", "sync": { diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json index 9c7abe84..142e5643 100644 --- a/frontend/app/src/assets/i18n/en.json +++ b/frontend/app/src/assets/i18n/en.json @@ -550,6 +550,7 @@ "resetToast.message": "Settings reset", "title": "Settings", "resetSettings": "Reset Settings", + "reloadPage": "Reload required", "calendar": { "title": "Calendar", "sync": { diff --git a/frontend/app/src/index.html b/frontend/app/src/index.html index cf3ded06..9bf40285 100644 --- a/frontend/app/src/index.html +++ b/frontend/app/src/index.html @@ -3,6 +3,7 @@ + StApps @@ -24,7 +25,7 @@ - + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d819bc8..5d51a526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -842,6 +842,9 @@ importers: deepmerge: specifier: 4.3.1 version: 4.3.1 + fast-deep-equal: + specifier: 3.1.3 + version: 3.1.3 form-data: specifier: 4.0.0 version: 4.0.0 @@ -1032,9 +1035,6 @@ importers: eslint-plugin-unicorn: specifier: 47.0.0 version: 47.0.0(eslint@8.43.0) - fast-deep-equal: - specifier: 3.1.3 - version: 3.1.3 fontkit: specifier: 2.0.2 version: 2.0.2