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
Show file tree
Hide file tree
Changes from 19 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";

/*********************************************************************************************
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";
jfmcquade marked this conversation as resolved.
Show resolved Hide resolved

/*********************************************************************************************
* 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 !@local.@local.dynamicNested)
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@domain.com)
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());
chrismclarke marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading