Skip to content

Commit

Permalink
Merge pull request #2423 from IDEMSInternational/refactor/app-config
Browse files Browse the repository at this point in the history
Refactor: app config runtime
  • Loading branch information
chrismclarke authored Sep 19, 2024
2 parents 9a0b3bb + f1f6fcb commit 3ee86dc
Show file tree
Hide file tree
Showing 27 changed files with 598 additions and 214 deletions.
17 changes: 9 additions & 8 deletions packages/data-models/appConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference lib="dom" />
import APP_CONFIG_GLOBALS from "./app-config/globals";
import clone from "clone";
import { type RecursivePartial } from "shared/src/types";
import { IAppSkin } from "./skin.model";

/*********************************************************************************************
Expand Down Expand Up @@ -222,14 +223,14 @@ const APP_CONFIG = {
SERVER_SYNC_FREQUENCY_MS,
TASKS,
};
// Export as a clone to avoid risk one import could alter another
export const getDefaultAppConfig = () => clone(APP_CONFIG);
export type IAppConfig = typeof APP_CONFIG;

/** A recursive version of Partial, making all properties, included nested ones, optional.
* Copied from https://stackoverflow.com/a/47914631
/**
* Get full app config populated with default values
* Returned as an editable clone so that changes will not impact original
*/
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
export const getDefaultAppConfig = (): IAppConfig => clone(APP_CONFIG);

export type IAppConfig = typeof APP_CONFIG;

/** Config overrides support deep-nested partials, merged with defaults at runtime */
export type IAppConfigOverride = RecursivePartial<IAppConfig>;
17 changes: 12 additions & 5 deletions packages/data-models/deployment.model.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { IGdriveEntry } from "../@idemsInternational/gdrive-tools";
import type { IAppConfigOverride } from "./appConfig";
import type { IAppConfig, IAppConfigOverride } from "./appConfig";

/** Update version to force recompile next time deployment set (e.g. after default config update) */
export const DEPLOYMENT_CONFIG_VERSION = 20240912.0;
export const DEPLOYMENT_CONFIG_VERSION = 20240914.0;

/** Configuration settings available to runtime application */
export interface IDeploymentRuntimeConfig {
Expand Down Expand Up @@ -152,6 +152,12 @@ interface IDeploymentCoreConfig {

export type IDeploymentConfig = IDeploymentCoreConfig & IDeploymentRuntimeConfig;

/**
* Generated config includes placeholders for all app_config entries to allow specific
* overrides for deeply nested properties, e.g. `app_config.NOTIFICATION_DEFAULTS.time.hour`
*/
export type IDeploymentConfigGenerated = IDeploymentConfig & { app_config: IAppConfig };

/** Deployment with additional metadata when set as active deployment */
export interface IDeploymentConfigJson extends IDeploymentConfig {
_workspace_path: string;
Expand All @@ -175,7 +181,6 @@ export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = {
endpoint: "https://apps-server.idems.international/analytics",
},
app_config: {},

firebase: {
config: null,
auth: { enabled: false },
Expand All @@ -188,9 +193,11 @@ export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = {
};

/** Full example of just all config once merged with defaults */
export const DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS: IDeploymentConfig = {
export const DEPLOYMENT_CONFIG_DEFAULTS: IDeploymentConfig = {
...DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS,
name: "Full Config Example",
// NOTE - app_config will be populated during config generation
app_config: {} as any,
name: "",
google_drive: {
assets_folder_id: "",
sheets_folder_id: "",
Expand Down
2 changes: 1 addition & 1 deletion packages/data-models/flowTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import type { IDataPipeOperation } from "shared";
import type { IAppConfig } from "./appConfig";
import { IAssetEntry } from "./deployment.model";
import type { IAssetEntry } from "./deployment.model";

/*********************************************************************************************
* Base flow types
Expand Down
3 changes: 2 additions & 1 deletion packages/data-models/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function extractDynamicFields(data: any): FlowTypes.IDynamicField | undef
export function extractDynamicEvaluators(
fullExpression: string
): FlowTypes.TemplateRowDynamicEvaluator[] | null {
const appConfigDefault = getDefaultAppConfig();
// match fields such as @local.someField
// deeper nesting will be need to be handled after evaluation as part of JSEvaluation
// (e.g. @local.somefield.nestedProperty or even !@[email protected])
Expand All @@ -89,7 +90,7 @@ export function extractDynamicEvaluators(
type = "raw";
}
// cross-check to ensure lookup matches one of the pre-defined dynamic field types (e.g. not [email protected])
if (!getDefaultAppConfig().DYNAMIC_PREFIXES.includes(type)) {
if (!appConfigDefault.DYNAMIC_PREFIXES.includes(type)) {
return undefined;
}
return { fullExpression, matchedExpression, type, fieldName };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from "crypto";

import { AssetsPostProcessor } from "./assets";
import type { IDeploymentConfigJson } from "../../deployment/common";
import type { RecursivePartial } from "data-models/appConfig";
import { type RecursivePartial } from "shared/src/types";

import { readJsonSync, readdirSync, statSync, existsSync } from "fs-extra";
import { vol } from "memfs";
Expand Down
18 changes: 13 additions & 5 deletions packages/scripts/src/commands/deployment/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ import { logWarning } from "shared";
import { readJSONSync } from "fs-extra";
import path from "path";

import { DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS, getDefaultAppConfig } from "data-models";
import type { IDeploymentConfig, IDeploymentConfigJson } from "data-models";
import { DEPLOYMENT_CONFIG_DEFAULTS, getDefaultAppConfig } from "data-models";
import type {
IDeploymentConfig,
IDeploymentConfigGenerated,
IDeploymentConfigJson,
} from "data-models";

import { DEPLOYMENTS_PATH } from "../../paths";
import { getStackFileNames, loadDeploymentJson } from "./utils";
import { toEmptyObject } from "shared/src/utils/object-utils";

// re-export of type for convenience
export type { IDeploymentConfigJson };

/** Create a new deployment config with default values */
export function generateDeploymentConfig(name: string): IDeploymentConfig {
const config = DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS;
export function generateDeploymentConfig(name: string) {
// populate placeholder properties for all nested appConfig to make it easier to
// apply overrides to single nested properties
const app_config = toEmptyObject(getDefaultAppConfig());
// combine with deployment config defaults
const config: IDeploymentConfigGenerated = { ...DEPLOYMENT_CONFIG_DEFAULTS, app_config };
config.name = name;
config.app_config = getDefaultAppConfig();
return config;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/scripts/src/commands/deployment/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ROOT_DIR } from "../../paths";
import { Logger } from "../../utils";
import { IDeploymentConfigJson } from "./common";
import { convertFunctionsToStrings } from "./utils";
import { cleanEmptyObject } from "shared/src/utils/object-utils";

const program = new Command("compile");
interface IOptions {
Expand Down Expand Up @@ -82,6 +83,9 @@ function convertDeploymentTsToJson(

const converted = convertFunctionsToStrings(rewritten);

// remove empty placeholders populated by override config
converted.app_config = cleanEmptyObject(converted.app_config);

return { ...converted, _workspace_path, _config_ts_path, _config_version };
}

Expand Down
2 changes: 1 addition & 1 deletion packages/scripts/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Provide access to FlowTypes used in the app
export { FlowTypes, IDeploymentConfig, DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS } from "data-models";
export { FlowTypes, IDeploymentConfig } from "data-models";
7 changes: 7 additions & 0 deletions packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ export interface ITemplatedStringVariable {
value?: string;
variables?: { [key: string]: ITemplatedStringVariable };
}

/** A recursive version of Partial, making all properties, included nested ones, optional.
* Copied from https://stackoverflow.com/a/47914631
*/
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
57 changes: 57 additions & 0 deletions packages/shared/src/utils/object-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
cleanEmptyObject,
isEmptyObjectDeep,
isObjectLiteral,
toEmptyObject,
} from "./object-utils";

const MOCK_NESTED_OBJECT = {
obj_1: {
obj_1_1: {
number: 2,
obj_1_1_1: {},
},
obj_1_2: {
obj_1_2_1: {},
},
},
string: "hi",
number: 1,
};

describe("Object Utils", () => {
it("isObjectLiteral", () => {
expect(isObjectLiteral({})).toEqual(true);
expect(isObjectLiteral({ string: "hello" })).toEqual(true);
expect(isObjectLiteral(undefined)).toEqual(false);
expect(isObjectLiteral([])).toEqual(false);
expect(isObjectLiteral(new Date())).toEqual(false);
});

it("isEmptyObjectDeep", () => {
expect(isEmptyObjectDeep({})).toEqual(true);
expect(isEmptyObjectDeep(undefined)).toEqual(false);
expect(isEmptyObjectDeep({ key: { key: { key: {} } } })).toEqual(true);
expect(isEmptyObjectDeep({ key: { key: { key: undefined } } })).toEqual(false);
});

it("toEmptyObject", () => {
const res = toEmptyObject(MOCK_NESTED_OBJECT);
expect(res).toEqual({
obj_1: { obj_1_1: { obj_1_1_1: {} }, obj_1_2: { obj_1_2_1: {} } },
} as any);
});

it("cleanEmptyObject", () => {
const res = cleanEmptyObject(MOCK_NESTED_OBJECT);
expect(res).toEqual({
obj_1: {
obj_1_1: {
number: 2,
},
},
string: "hi",
number: 1,
});
});
});
64 changes: 64 additions & 0 deletions packages/shared/src/utils/object-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Determine whether a value is a literal object type (`{}`)
* Adapted from discussion https://stackoverflow.com/q/1173549
*/
export function isObjectLiteral(v: any) {
return v ? v.constructor === {}.constructor : false;
}

/** Check if an object is either empty or contains only empty child */
export function isEmptyObjectDeep(v: any) {
return isObjectLiteral(v) && Object.values(v).every((x) => isEmptyObjectDeep(x));
}

/**
* Takes a json object and empties all data inside, just leaving nested entry nodes
* This is used to create placeholder objects for deeply nested partial configurations
* @example
* ```ts
* const obj = {parent:{text:'hello',obj:{number:1}}}
* toEmptyObject(obj)
* // output
* {parent:{obj:{}}}
* ```
***/
export function toEmptyObject<T extends Record<string, any>>(obj: T) {
const emptied = {} as any;
if (isObjectLiteral(obj)) {
for (const [key, value] of Object.entries(obj)) {
if (isObjectLiteral(value)) {
emptied[key] = toEmptyObject(value);
}
}
} else {
console.error("[toEmptyObject] invalid input: " + obj);
return obj;
}
return emptied as T;
}

/**
* Takes an input object with deeply nested keys and removes all child entries
* that are either empty `{}` or contain only empty child entries `{nested:{}}`
* @example
* ```ts
*
* ```
*/
export function cleanEmptyObject(obj: Record<string, any>) {
const cleaned = {} as any;
if (obj.constructor === {}.constructor) {
for (const [key, value] of Object.entries(obj)) {
if (value.constructor === {}.constructor) {
if (!isEmptyObjectDeep(value)) {
cleaned[key] = cleanEmptyObject(value);
}
} else {
cleaned[key] = value;
}
}
} else {
return cleaned;
}
return cleaned;
}
23 changes: 8 additions & 15 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { NgModule } from "@angular/core";
import { PreloadAllModules, Route, RouterModule, Routes } from "@angular/router";
import { APP_CONFIG } from "./data";
import { PreloadAllModules, RouterModule, Routes } from "@angular/router";
import { TourComponent } from "./feature/tour/tour.component";

// TODO: These should come from the appConfigService
const { APP_ROUTE_DEFAULTS } = APP_CONFIG;

/** Routes specified from data-models */
const DataRoutes: Routes = [
{ path: "", redirectTo: APP_ROUTE_DEFAULTS.home_route, pathMatch: "full" },
...APP_ROUTE_DEFAULTS.redirects,
];
const fallbackRoute: Route = { path: "**", redirectTo: APP_ROUTE_DEFAULTS.fallback_route };

/** Routes required for main app features */
const FeatureRoutes: Routes = [
/**
* Routes required for main app features
* Additional home template redirects and fallback routes will be specified
* from deployment config via the AppConfigService
**/
export const APP_FEATURE_ROUTES: Routes = [
{
path: "campaigns",
loadChildren: () => import("./feature/campaign/campaign.module").then((m) => m.CampaignModule),
Expand Down Expand Up @@ -68,7 +61,7 @@ const FeatureRoutes: Routes = [

@NgModule({
imports: [
RouterModule.forRoot([...FeatureRoutes, ...DataRoutes, fallbackRoute], {
RouterModule.forRoot(APP_FEATURE_ROUTES, {
preloadingStrategy: PreloadAllModules,
useHash: false,
anchorScrolling: "enabled",
Expand Down
8 changes: 0 additions & 8 deletions src/app/data/constants.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/app/data/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from "./constants";
export * from "./app-data";

// Not used but forces angular to reload when asset jsons changed
Expand Down
32 changes: 31 additions & 1 deletion src/app/feature/theme/services/theme.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import { TestBed } from "@angular/core/testing";

import { ThemeService } from "./theme.service";
import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service";
import { MockLocalStorageService } from "src/app/shared/services/local-storage/local-storage.service.spec";
import { AppConfigService } from "src/app/shared/services/app-config/app-config.service";
import { MockAppConfigService } from "src/app/shared/services/app-config/app-config.service.spec";
import { IAppConfig } from "packages/data-models";

export class MockThemeService implements Partial<ThemeService> {
ready() {
return true;
}
setTheme() {}
getCurrentTheme() {
return "mock_theme";
}
}

const MOCK_APP_CONFIG: Partial<IAppConfig> = {
APP_THEMES: {
available: ["MOCK_THEME_1", "MOCK_THEME_2"],
defaultThemeName: "MOCK_THEME_1",
},
};

describe("ThemeService", () => {
let service: ThemeService;

beforeEach(() => {
TestBed.configureTestingModule({});
TestBed.configureTestingModule({
providers: [
{ provide: LocalStorageService, useValue: new MockLocalStorageService() },
{
provide: AppConfigService,
useValue: new MockAppConfigService(MOCK_APP_CONFIG),
},
],
});
service = TestBed.inject(ThemeService);
});

Expand Down
Loading

0 comments on commit 3ee86dc

Please sign in to comment.