Skip to content

[Angular] Dynamic App Config and Translations in a Submodule

Georg Höller
Georg Höller
7 min read
[Angular] Dynamic App Config and Translations in a Submodule
Photo by Sergey Zolkin / Unsplash

When I'm setting up a new angular application, I always start with translation support and an external app config json loader. You should never start a new app without an internationalization (i18n) system - even if you only support one language! We are going to use the package @ngx-translate/core.
The app config isn't essential but very practical if you want to change some values without rebuilding and deploying your app. Very inspired by this MS DevBlog!

After multiple app creations and copying the same files over and over again I put the main logic inside a submodule utils folder. Now I can import the submodule in every new project I start and use it like an npm package. If you want to learn more about submodules:

[nx.dev | Angular] Libraries as Git Submodule - DRY Principle
Recently I published a post about my single project workspace structure usingnx.dev. I mention that we are able to stick to the DRY principle even if wedon’t use nx.dev as a mono repo! [Angular | nx.dev] Single Project Workspace StructureThe concept of a monorepois great

I will reference to this source code:

ngmug/frontend/ngmug at main · Georg632/ngmug
Contribute to Georg632/ngmug development by creating an account on GitHub.

To have a better overview which files we will create and how it should look like in the end:

App Config

Setting File Setup

First we have to define where the config file lives - normally i put it under assets/customization.

Create a new json file and add some content - mine looks like the following:

{
  "api": {
    "url": "/api"
  },
  "language": ["en-US"],
  "title": "ngmug",
  "cookiePrefix": "ngmug",
  "logoUrl": "./assets/images/logo/ngmug.png"
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/assets/customization/settings.json

Now we have to define a constant (or use the environment file) to set the settings file's path. I created this object in the app.module.ts:

export const environment = {
  settingsFile: `assets/customization/settings.json?version=${Date.now()}`,
};
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts

Middleware Service

Create a new service for your app ng g s app-config. This should be the middleware service between the app and the generic submodule:

@Injectable({
  providedIn: 'root',
})
export class AppConfigService extends GhConfigService<AppConfig> {
  static settings: AppConfig;
  defaultValue: AppConfig = {
    api: {
      url: '',
    },
    language: ['de-DE', 'en-US'],
    title: 'Title',
    logoUrl: 'TBD',
    cookiePrefix: '',
  };

  constructor(
    platformLocation: PlatformLocation,
    titleService: Title,
    httpback: HttpBackend
  ) {
    super(new HttpClient(httpback), platformLocation, titleService);
  }

  override async load(configPath: string): Promise<AppConfig> {
    AppConfigService.settings = await super.load(configPath, this.defaultValue);
    return AppConfigService.settings;
  }
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/libs/shared/ngmug-utils/src/lib/app-config/app-config.service.ts
☝️
You may have recognised the HttpBackend in the constructor - this is important if you have active http interceptors! Creating a HttpClient with HttpBackend, the interceptors getting ignored!

Beside the service, create a new file with the app specific config interface:

export interface AppConfig extends GhAppConfig {
  cookiePrefix: string;
  logoUrl: string;
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/libs/shared/ngmug-utils/src/lib/app-config/app-config.ts

Submodule Service

Let's create the generic service inside your submodule library:


export class GhConfigService<TAppConfig extends GhAppConfig> {
  constructor(
    protected _http: HttpClient,
    protected _platformLocation: PlatformLocation,
    protected _titleService: Title
  ) {}

  load(configPath: string, defaultValues: any): Promise<TAppConfig> {
    return new Promise<TAppConfig>((resolve, reject) => {
      lastValueFrom(this._http.get(configPath))
        .then((response: any) => {
          var settings = response as TAppConfig;
          this.setDefaultValues(settings, defaultValues);
          //inject default values if not set
          settings.api.url = this.replaceFQDN(settings.api.url);
          this._titleService.setTitle(settings.title);
          resolve(settings as TAppConfig);
        })
        .catch((response: any) => {
          console.log('cfg error');
          reject(
            `Could not load configuration file '${configPath}': ${JSON.stringify(
              response
            )}`
          );
        });
    });
  }

  private setDefaultValues(target: any, source: any) {
    for (const key of Reflect.ownKeys(source)) {
      const sourceChild = source[key];
      const sourceChildType = typeof sourceChild;
      const isArray = Array.isArray(sourceChild);

      if (!this.isDefined(target[key], sourceChildType, isArray)) {
        //property missing: set
        target[key] = sourceChild;
        continue;
      }

      if (sourceChildType === 'object') {
        //traverse child object
        if (!isArray) {
          this.setDefaultValues(target[key], sourceChild);
        }
      } else if (sourceChildType === 'string') {
        //overwrite if only empty string is set
        if ((target[key] as string).length <= 0) {
          target[key] = sourceChild;
        }
      }
    }
  }

  private isDefined(
    value: any,
    expectedType: string,
    isArray: boolean
  ): boolean {
    if (value === null || value === undefined) {
      //missing -> not defined
      return false;
    }
    if (expectedType !== typeof value) {
      //wrong type -> not defined
      return false;
    }

    if (isArray !== Array.isArray(value)) {
      //array missmatch -> not defined
      return false;
    }

    return true;
  }

  replaceFQDN(url: string): string {
    return url.replace('$host', this._platformLocation.hostname);
  }
}
https://github.com/Georg632/gh-utils/blob/main/src/lib/utils/gh-config/gh-config.service.ts

And add a file to define the default config interface:

export interface GhAppConfig {
  title: string;
  language: string[];
  api: {
    url: string;
  };
}
https://github.com/Georg632/gh-utils/blob/main/src/lib/utils/gh-config/gh-config.ts

The service just makes an http call to the defined assets file and loads the content. If everything works as expected it will get set the result to a static property to be available from the entire application!

Put everything together

The last thing we have to do is actually calling it - switch back to your app.module.ts and add the following provider and the app init factory!
(if you don't need the translation part, remove it!)

providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: AppInit,
      deps: [AppConfigService, TranslationManagementService],
      multi: true,
    },
  ],
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts
export function AppInit(
  configService: AppConfigService,
  transService: TranslationManagementService
) {
  return async () => {
    await configService.load(environment.settingsFile);
    transService.init();
  };
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts

You can use your app config properties like this:

AppConfigService.settings.api.url;

Translation

Language Files Setup

Add to properties your environment file or object which we created in the app config section:

export const environment = {
  settingsFile: `assets/customization/settings.json?version=${Date.now()}`,
  languagesFolder: 'assets/languages/',
  languagesSuffix: `.json?version=${Date.now()}`,
};
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts

Don't forget to change the path to your translation files! Mine look like the following:

Initialize Packages

I'm using ngx-translate for my i18n - it's a great!

npm i @ngx-translate/core @ngx-translate/http-loader

Add this to your imports to load your translation files per http:

    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient],
      },
    }),
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts
export function HttpLoaderFactory(httpClient: HttpClient): TranslateHttpLoader {
  return new TranslateHttpLoader(
    httpClient,
    environment.languagesFolder,
    environment.languagesSuffix
  );
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/apps/ngmug-portal/src/app/app.module.ts

Middleware Service

Same story as above - add a service to your shared folder which is specific for this app and add this to it:
(the middleware isn't necessary - but maybe a needed abstraction)

@Injectable({
  providedIn: 'root',
})
export class TranslationManagementService extends GhTranslationService {
  constructor(ngxTranslateService: TranslateService) {
    super(ngxTranslateService);
  }

  override init(): void {
    super.init(
      AppConfigService.settings.language,
      AppConfigService.settings.cookiePrefix,
      getCookie,
      setCookie
    );
  }
}
https://github.com/Georg632/ngmug/blob/main/frontend/ngmug/libs/shared/ngmug-utils/src/lib/translation-management/translation-management.service.ts

The getCookie and setCookie are just util functions - you will find them here if you need them:

ngmug/cookie.ts at main · Georg632/ngmug
Contribute to Georg632/ngmug development by creating an account on GitHub.

Submodule Service

My translation service adds some automatic detecting logic - you can delete it if you don't need it. The important part is the init function!

registerLocaleData(localEn, 'en');

@Injectable({
  providedIn: 'root',
})
export class GhTranslationService {
  defaultLanguage: string = '';
  supportedLanguages: string[] = [];
  onLangChange = this.ngxTranslateService.onLangChange;
  cookiePrefix: string = '';
  getCookie!: Function;
  setCookie!: Function;

  constructor(private ngxTranslateService: TranslateService) {}

  get languageCookie(): string {
    return `${this.cookiePrefix}appLanguage`;
  }

  set language(desiredLanguage: string) {
    desiredLanguage =
      desiredLanguage ||
      (this.ngxTranslateService.getBrowserCultureLang() ?? '');

    let usedLanguage = GhTranslationService.findBestMatchingLanguage(
      desiredLanguage,
      this.supportedLanguages
    );
    if (!usedLanguage) {
      usedLanguage = this.defaultLanguage;
    }

    this.setCookie(this.languageCookie, usedLanguage, 360);
    this.ngxTranslateService.use(usedLanguage);
  }

  get language(): string {
    return this.ngxTranslateService.currentLang;
  }

  static findBestMatchingLanguage(
    language: string,
    supportedLanguages: string[]
  ) {
    if (!language) {
      language = '';
    }

    language = language.toLowerCase();
    const shortLanguage = language.split('-')[0];

    //try to find exact match
    let result = supportedLanguages.find((sl) => sl.toLowerCase() === language);

    //try to remove "-" of target language (en-US -> en)
    if (!result) {
      result = supportedLanguages.find(
        (sl) => sl.toLowerCase() === shortLanguage
      );
    }

    //try to remove "-" of supported languages
    if (!result) {
      result = supportedLanguages.find(
        (sl) => sl.toLowerCase().split('-')[0] === shortLanguage
      );
    }

    return result;
  }

  init(
    appLanguages: string[],
    cookiePrefix: string,
    getCookie: Function,
    setCookie: Function
  ) {
    if (appLanguages.length < 1)
      throw new Error('Init Languages Error: No Language found in settings');
    this.supportedLanguages = appLanguages;
    this.cookiePrefix = cookiePrefix;
    this.getCookie = getCookie;
    this.setCookie = setCookie;

    const defaultLanguage = appLanguages[0];
    this.ngxTranslateService.setDefaultLang(defaultLanguage);
    this.defaultLanguage = defaultLanguage;
    const persistedLang = this.getCookie(this.languageCookie);
    this.language = persistedLang;
  }

  instant(key: string): string {
    return this.ngxTranslateService.instant(key);
  }
}
https://github.com/Georg632/gh-utils/blob/main/src/lib/utils/gh-translation/gh-translation.service.ts

Put everything together

Now the translation should run - for example:

<input [placeholder]="'general.test' | translate" />

Remember to import TranslateModule from @ngx-translate/core when you need translations in another module!

If you have further questions about this topic or need help with a problem in general, please write a comment or simply contact me at yesreply@georghoeller.dev :)
Angular

Comments


Related Posts

Members Public

[Angular | Storybook] Tailwind, Directives, Content Projection, Icons and i18n

Install packages npm i -D @nx/storybook npm i -D @storybook/angular Create config nx g @nx/storybook:configuration <project-name> I created the config inside the main app to share some configs/modules. My stories are located at libs/shared/ui. That's why I had to

[Angular | Storybook] Tailwind, Directives, Content Projection, Icons and i18n
Members Public

[Angular | RxJS] BehaviorSubject with custom states

Whenever I call an api, often my frontend has to go through some different visualization steps. Imagine you have some kind of search input and a list which displays the results but you want to show an loading indicator, an error hint and something different when the result is empty.

[Angular | RxJS] BehaviorSubject with custom states
Members Public

[Capacitorjs | Angular] Back Swipe Support for iOS and Android

Recently I recognized that capacitorjs apps does not have a native back swipe support. Little bit weird I thought but the solution is quite simple! iOS - Native To enable the iOS back swipe we need to write a capacitor plugin. I tried it without a plugin but inside the

[Capacitorjs | Angular] Back Swipe Support for iOS and Android