Skip to content

Commit

Permalink
refactor: app config service override hierarchy
Browse files Browse the repository at this point in the history
  • Loading branch information
chrismclarke committed Nov 22, 2024
1 parent c5c661a commit 66a9198
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Router } from "@angular/router";
import { toSignal } from "@angular/core/rxjs-interop";
import { ngRouterMergedSnapshot$ } from "src/app/shared/utils/angular.utils";
import { isEqual } from "packages/shared/src/utils/object-utils";
import { AppConfigService } from "src/app/shared/services/app-config/app-config.service";

/**
* Service responsible for handling metadata of the current top-level template,
Expand All @@ -26,6 +27,7 @@ export class TemplateMetadataService extends SyncServiceBase {

constructor(
private templateService: TemplateService,
private appConfigService: AppConfigService,
private router: Router
) {
super("TemplateMetadata");
Expand All @@ -42,5 +44,10 @@ export class TemplateMetadataService extends SyncServiceBase {
},
{ allowSignalWrites: true }
);
// apply any template-specific appConfig overrides on change
effect(() => {
const templateAppConfig = this.parameterList().app_config;
this.appConfigService.setAppConfig(templateAppConfig, "template");
});
}
}
41 changes: 25 additions & 16 deletions src/app/shared/services/app-config/app-config.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ import { TestBed } from "@angular/core/testing";
import { AppConfigService } from "./app-config.service";
import { BehaviorSubject } from "rxjs/internal/BehaviorSubject";
import { IAppConfig } from "../../model";
import { signal } from "@angular/core";
import { signal, WritableSignal } from "@angular/core";
import { DeploymentService } from "../deployment/deployment.service";
import {
getDefaultAppConfig,
IAppConfigOverride,
IDeploymentRuntimeConfig,
} from "packages/data-models";
import { IAppConfigOverride, IDeploymentRuntimeConfig } from "packages/data-models";
import { deepMergeObjects } from "../../utils";
import { firstValueFrom } from "rxjs/internal/firstValueFrom";
import { MockDeploymentService } from "../deployment/deployment.service.spec";
Expand Down Expand Up @@ -46,6 +42,7 @@ const MOCK_DEPLOYMENT_CONFIG: Partial<IDeploymentRuntimeConfig> = {
*/
describe("AppConfigService", () => {
let service: AppConfigService;
let appConfigSetSpy: jasmine.Spy<WritableSignal<IAppConfig>>;

beforeEach(() => {
TestBed.configureTestingModule({
Expand All @@ -54,24 +51,20 @@ describe("AppConfigService", () => {
],
});
service = TestBed.inject(AppConfigService);
appConfigSetSpy = spyOn(service.appConfig, "set").and.callThrough();
});

it("applies default config overrides on init", () => {
expect(service.appConfig().APP_HEADER_DEFAULTS.title).toEqual(
getDefaultAppConfig().APP_HEADER_DEFAULTS.title
);
expect(service.appConfig().APP_HEADER_DEFAULTS.title).toEqual("App");
});

it("applies deployment-specific config overrides on init", () => {
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("mock_footer");
});

it("applies overrides to app config", () => {
service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "updated" } });
expect(service.appConfig().APP_HEADER_DEFAULTS).toEqual({
...getDefaultAppConfig().APP_HEADER_DEFAULTS,
title: "updated",
});
it("applies skin-level overrides to app config", () => {
service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "updated" } }, "skin");
expect(service.appConfig().APP_HEADER_DEFAULTS.title).toEqual("updated");
// also ensure doesn't unset default deployment
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("mock_footer");
});
Expand All @@ -80,7 +73,23 @@ describe("AppConfigService", () => {
firstValueFrom(service.changes$).then((v) => {
expect(v).toEqual({ APP_HEADER_DEFAULTS: { title: "partial changes" } });
});
service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "partial changes" } }, "skin");
expect(appConfigSetSpy).toHaveBeenCalledTimes(1);
});

service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "partial changes" } });
it("ignores lower-order updates when higher order exists", async () => {
service.setAppConfig({ APP_FOOTER_DEFAULTS: { templateName: "template_footer" } }, "template");
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("template_footer");
service.setAppConfig({ APP_FOOTER_DEFAULTS: { templateName: "skin_footer" } }, "skin");
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("template_footer");
// the second service set should not trigger any changes to appConfig signal (or observable)
expect(appConfigSetSpy).toHaveBeenCalledTimes(1);
});

it("reverts to initial config values when template override removed", async () => {
service.setAppConfig({ APP_FOOTER_DEFAULTS: { templateName: "template_footer" } }, "template");
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("template_footer");
service.setAppConfig({}, "template");
expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("mock_footer");
});
});
49 changes: 31 additions & 18 deletions src/app/shared/services/app-config/app-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,36 @@ import { Observable } from "rxjs";
import { DeploymentService } from "../deployment/deployment.service";
import { updateRoutingDefaults } from "./app-config.utils";
import { Router } from "@angular/router";
import { isEqual } from "packages/shared/src/utils/object-utils";

/** Config overrides can come from a variety of sources with orders of hierarchy */
const APP_CONFIG_OVERRIDE_ORDER = {
default: 0,
deployment: 1,
skin: 2,
template: 3,
};
type IAppConfigOverrideSource = keyof typeof APP_CONFIG_OVERRIDE_ORDER;

@Injectable({
providedIn: "root",
})
export class AppConfigService extends SyncServiceBase {
/**
* Initial config is generated by merging default app config with deployment-specific overrides
* It is accessed via a read-only getter to avoid update from methods
**/
private readonly initialConfig: IAppConfig = deepMergeObjects(
getDefaultAppConfig(),
this.deploymentService.config.app_config
);

/** Signal representation of current appConfig value */
public appConfig = signal(this.initialConfig);
public appConfig = signal<IAppConfig>(undefined);

/**
* @deprecated - prefer use of config signal and computed/effect bindings
* List of constants provided by data-models combined with deployment-specific overrides and skin-specific overrides
**/
public appConfig$ = new BehaviorSubject(this.initialConfig);
public appConfig$ = new BehaviorSubject<IAppConfig>(undefined);

/** Tracking observable of deep changes to app config, exposed in `changes` public method */
private appConfigChanges$: Observable<RecursivePartial<IAppConfig>>;

/** Array of all applied config overrides. Array position represents override hierarchy order (0-3) */
private configOverrides: IAppConfigOverride[] = [];

/**
* @deprecated - prefer use of config signal and computed/effect bindings
*
Expand Down Expand Up @@ -63,20 +67,29 @@ export class AppConfigService extends SyncServiceBase {
this.initialise();
}

/** When service initialises load initial config to trigger any side-effects */
/** When service initialises load config defaults and deployment to trigger any side-effects */
private initialise() {
this.setAppConfig(this.initialConfig);
this.setAppConfig(getDefaultAppConfig(), "default");
this.setAppConfig(this.deploymentService.config.app_config, "deployment");
}

/**
* Generate a complete app config by deep-merging app config overrides
* with the initial config
*/
public setAppConfig(overrides: IAppConfigOverride = {}) {
const mergedConfig = deepMergeObjects({} as IAppConfig, this.initialConfig, overrides);
this.handleConfigSideEffects(overrides, mergedConfig);
this.appConfig.set(mergedConfig);
this.appConfig$.next(mergedConfig);
public setAppConfig(overrides: IAppConfigOverride = {}, source: IAppConfigOverrideSource) {
const overrideIndex = APP_CONFIG_OVERRIDE_ORDER[source];
// replace any overrides at the existing level (e.g. skin or template)
this.configOverrides[overrideIndex] = overrides;
// merge all levels of override, with higher order levels merged on top of lower
const mergedConfig = deepMergeObjects({} as IAppConfig, ...this.configOverrides);

// trigger change effects only if config changed
if (!isEqual(this.appConfig(), mergedConfig)) {
this.handleConfigSideEffects(overrides, mergedConfig);
this.appConfig.set(mergedConfig);
this.appConfig$.next(mergedConfig);
}
}

private handleConfigSideEffects(overrides: IAppConfigOverride = {}, config: IAppConfig) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/shared/services/skin/skin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class SkinService extends SyncServiceBase {
const override = this.generateOverrideConfig(targetSkin);
const revert = this.generateRevertConfig(targetSkin);
console.log("[SKIN] SET", { targetSkin, override, revert });
this.appConfigService.setAppConfig(override);
this.appConfigService.setAppConfig(override, "skin");
this.revertOverride = revert;
this.currentSkin = targetSkin;

Expand Down

0 comments on commit 66a9198

Please sign in to comment.