Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: app config runtime #2423

Merged
merged 21 commits into from
Sep 19, 2024
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";

/*********************************************************************************************
@@ -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 {
@@ -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;
@@ -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 },
@@ -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: "",
2 changes: 1 addition & 1 deletion packages/data-models/flowTypes.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion packages/data-models/functions.ts
Original file line number Diff line number Diff line change
@@ -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 !@local.@local.dynamicNested)
@@ -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@domain.com)
if (!getDefaultAppConfig().DYNAMIC_PREFIXES.includes(type)) {
if (!appConfigDefault.DYNAMIC_PREFIXES.includes(type)) {
return undefined;
}
return { fullExpression, matchedExpression, type, fieldName };
Original file line number Diff line number Diff line change
@@ -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";
18 changes: 13 additions & 5 deletions packages/scripts/src/commands/deployment/common.ts
Original file line number Diff line number Diff line change
@@ -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;
}

4 changes: 4 additions & 0 deletions packages/scripts/src/commands/deployment/compile.ts
Original file line number Diff line number Diff line change
@@ -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 {
@@ -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 };
}

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
@@ -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),
@@ -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",
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
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);
});

Loading
Loading