diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.spec.ts index 43adf7d721..ce7beede78 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.spec.ts @@ -86,7 +86,7 @@ describe("Template Parser PostProcessor", () => { }); expect(case4.value).toEqual([ { key_1a: "textValue", key_1b: "@local.value" }, - { key_2a: "", key_2b: "5" }, + { key_2a: "", key_2b: 5 }, ]); }); diff --git a/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.ts b/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.ts index 9dab118df4..271dfb2c9a 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.ts @@ -7,7 +7,7 @@ import BaseProcessor from "./base"; import { existsSync } from "fs-extra"; import { IContentsEntry, parseAppDataCollectionString } from "../utils"; -const cacheVersion = 20230509.1; +const cacheVersion = 20241118.0; export class XLSXWorkbookProcessor extends BaseProcessor { constructor(paths: IConverterPaths) { diff --git a/packages/scripts/src/commands/app-data/convert/utils/app-data-string.utils.ts b/packages/scripts/src/commands/app-data/convert/utils/app-data-string.utils.ts index ed5c224104..887488acae 100644 --- a/packages/scripts/src/commands/app-data/convert/utils/app-data-string.utils.ts +++ b/packages/scripts/src/commands/app-data/convert/utils/app-data-string.utils.ts @@ -1,5 +1,6 @@ import { FlowTypes } from "data-models"; -import { setNestedProperty, booleanStringToBoolean } from "../utils"; +import { setNestedProperty } from "../utils"; +import { parseStringValue } from "shared/src/utils"; /** * Convert app data map string to object @@ -50,9 +51,13 @@ export function parseAppDataCollectionString( // handle keys that define deeper nesting, such as time.hours: 7 if (key.includes(".")) { const [base, ...nested] = key.split("."); - collection[base] = setNestedProperty(nested.join("."), value, collection[base]); + collection[base] = setNestedProperty( + nested.join("."), + parseStringValue(value), + collection[base] + ); } else { - collection[key] = booleanStringToBoolean(value); + collection[key] = parseStringValue(value); } }); return collection; diff --git a/src/app/shared/components/template/components/layout/popup/popup.component.ts b/src/app/shared/components/template/components/layout/popup/popup.component.ts index e7c097dda9..be72440925 100644 --- a/src/app/shared/components/template/components/layout/popup/popup.component.ts +++ b/src/app/shared/components/template/components/layout/popup/popup.component.ts @@ -1,6 +1,6 @@ import { Component, Input } from "@angular/core"; import { ModalController } from "@ionic/angular"; -import { FlowTypes, ITemplateContainerProps } from "../../../models"; +import { FlowTypes } from "../../../models"; import { TemplateContainerComponent } from "../../../template-container.component"; @Component({ @@ -41,11 +41,15 @@ export class TemplatePopupComponent { } } -export interface ITemplatePopupComponentProps extends ITemplateContainerProps { +/** Required inputs to pass on to TemplateContainer component */ +interface IContainerProps { name: string; templatename: string; - parent?: TemplateContainerComponent; row?: FlowTypes.TemplateRow; + parent?: TemplateContainerComponent; +} + +export interface ITemplatePopupComponentProps extends IContainerProps { showCloseButton?: boolean; /** Dismiss popup when completed or uncompleted is emitted from child template */ dismissOnEmit?: boolean; diff --git a/src/app/shared/components/template/models/index.ts b/src/app/shared/components/template/models/index.ts index 10b0b37d3b..f8f1de5bdd 100644 --- a/src/app/shared/components/template/models/index.ts +++ b/src/app/shared/components/template/models/index.ts @@ -2,20 +2,6 @@ import { FlowTypes } from "src/app/shared/model"; import { TemplateContainerComponent } from "../template-container.component"; export { FlowTypes } from "src/app/shared/model"; -/** - * Properties passed to a template container instance - * @param templateName - flow_name of template to lookup - * @param name - unique name for the template, in cases where multiple child templates of the same type may exist (e.g. multiple buttons) - * @param row - reference to the full row if template instantiated from a parent - * @param parent - reference to parent template (when nested) - */ -export interface ITemplateContainerProps { - templatename: string; - name: string; - parent?: { name: string; component?: any }; - row?: FlowTypes.TemplateRow; -} - /** * Properties passed to a template row instance * @param row specific data used in component rendering diff --git a/src/app/shared/components/template/services/template-metadata.service.ts b/src/app/shared/components/template/services/template-metadata.service.ts index a62b7ba523..b8b6df903f 100644 --- a/src/app/shared/components/template/services/template-metadata.service.ts +++ b/src/app/shared/components/template/services/template-metadata.service.ts @@ -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, @@ -26,6 +27,7 @@ export class TemplateMetadataService extends SyncServiceBase { constructor( private templateService: TemplateService, + private appConfigService: AppConfigService, private router: Router ) { super("TemplateMetadata"); @@ -42,5 +44,13 @@ 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"); + }, + { allowSignalWrites: true } + ); } } diff --git a/src/app/shared/components/template/services/template-nav.service.ts b/src/app/shared/components/template/services/template-nav.service.ts index af2b722444..26303895a7 100644 --- a/src/app/shared/components/template/services/template-nav.service.ts +++ b/src/app/shared/components/template/services/template-nav.service.ts @@ -10,7 +10,6 @@ import { ITemplatePopupComponentProps, TemplatePopupComponent, } from "../components/layout/popup/popup.component"; -import { ITemplateContainerProps } from "../models"; import { TemplateContainerComponent } from "../template-container.component"; // Toggle logs used across full service for debugging purposes (there's quite a few and tedious to comment) @@ -40,7 +39,7 @@ export class TemplateNavService extends SyncServiceBase { * unless specifically closed (e.g. nav triggered from modal) */ private openPopupsByName: { - [templatename: string]: { modal: HTMLIonModalElement; props: ITemplateContainerProps }; + [templatename: string]: { modal: HTMLIonModalElement; props: ITemplatePopupComponentProps }; } = {}; public async handleQueryParamChange( diff --git a/src/app/shared/components/template/template-component.ts b/src/app/shared/components/template/template-component.ts index dbd9609990..a120fd41d2 100644 --- a/src/app/shared/components/template/template-component.ts +++ b/src/app/shared/components/template/template-component.ts @@ -155,7 +155,8 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow componentRef.instance.row = row; componentRef.instance.parent = this.parent; componentRef.instance.name = row.name; - componentRef.instance.templatename = row.value; + // assign templatename input using signal + componentRef.setInput("templatename", row.value); this.componentRef = componentRef; } diff --git a/src/app/shared/components/template/template-container.component.html b/src/app/shared/components/template/template-container.component.html index c0966a73b1..8e6f73efc2 100644 --- a/src/app/shared/components/template/template-container.component.html +++ b/src/app/shared/components/template/template-container.component.html @@ -1,7 +1,7 @@
-
template: {{ templatename || "(undefined)" }}
+
template: {{ templatename() || "(undefined)" }}
name: {{ name || "(undefined)" }}
diff --git a/src/app/shared/components/template/template-container.component.ts b/src/app/shared/components/template/template-container.component.ts index 1fe5170441..b71c913f98 100644 --- a/src/app/shared/components/template/template-container.component.ts +++ b/src/app/shared/components/template/template-container.component.ts @@ -1,10 +1,12 @@ import { ChangeDetectorRef, Component, + effect, ElementRef, EventEmitter, HostBinding, Injector, + input, Input, OnDestroy, OnInit, @@ -13,7 +15,7 @@ import { import { ActivatedRoute } from "@angular/router"; import { takeUntil } from "rxjs/operators"; import { Subject } from "rxjs"; -import { FlowTypes, ITemplateContainerProps } from "./models"; +import { FlowTypes } from "./models"; import { TemplateActionService } from "./services/instance/template-action.service"; import { TemplateNavService } from "./services/template-nav.service"; import { TemplateRowService } from "./services/instance/template-row.service"; @@ -37,12 +39,14 @@ let log_groupEnd = SHOW_DEBUG_LOGS ? console.groupEnd : () => null; * - Track dynamic variable dependency (to know when to trigger row change based on set_local/field/global events) * - Consider case of template container re-render (some draft cached code exists, but not sure if this is a valid use-case or not) */ -export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateContainerProps { +export class TemplateContainerComponent implements OnInit, OnDestroy { /** unique instance_name of template if created as a child of another template */ @Input() name: string; /** flow_name of template for lookup */ - @Input() templatename: string; + templatename = input.required(); + /** reference to parent template (when nested) */ @Input() parent?: TemplateContainerComponent; + /** reference to the full row if template instantiated from a parent */ @Input() row?: FlowTypes.TemplateRow; /** Allow parents to also see emitted value (note - currently responding to emit is done in service, not output bindings except for ) */ @Output() emittedValue = new EventEmitter<{ emit_value: string; emit_data: any }>(); @@ -73,13 +77,17 @@ export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateC public elRef?: ElementRef, private route?: ActivatedRoute ) { + effect(() => { + // re-render template whenever input template name changes + const templatename = this.templatename(); + this.hostTemplateName = templatename; + this.renderTemplate(templatename); + }); this.templateActionService = new TemplateActionService(injector, this); this.templateRowService = new TemplateRowService(injector, this); } /** Assign the templatename as metdaata on the component for easier debugging and testing */ - @HostBinding("attr.data-templatename") get getTemplatename() { - return this.templatename; - } + @HostBinding("attr.data-templatename") public hostTemplateName: string; async ngOnInit() { if (!this.ignoreQueryParamChanges) { @@ -90,7 +98,6 @@ export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateC this.setLogging(true); this.templateRowService.setLogging(true); } - await this.renderTemplate(); } ngOnDestroy(): void { @@ -136,7 +143,7 @@ export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateC this.templateRowService.renderedRows = []; // allow time for other pending ops to finish await _wait(50); - await this.renderTemplate(); + await this.renderTemplate(this.templatename()); } else { await this.templateRowService.processRowUpdates(); console.log("[Force Reprocess]", this.name, this.templateRowService.renderedRows); @@ -174,13 +181,13 @@ export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateC * Template Initialisation **************************************************************************************/ - private async renderTemplate() { + private async renderTemplate(templatename: string) { // Lookup template const template = await this.templateService.getTemplateByName( - this.templatename, + templatename, this.row?.is_override_target ); - this.name = this.name || this.templatename; + this.name = this.name || templatename; this.templateBreadcrumbs = [...(this.parent?.templateBreadcrumbs || []), this.name]; this.template = template; log_group("[Template Render Start]", this.name); diff --git a/src/app/shared/services/app-config/app-config.service.spec.ts b/src/app/shared/services/app-config/app-config.service.spec.ts index 2b73be5c0f..69b7340887 100644 --- a/src/app/shared/services/app-config/app-config.service.spec.ts +++ b/src/app/shared/services/app-config/app-config.service.spec.ts @@ -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"; @@ -46,6 +42,7 @@ const MOCK_DEPLOYMENT_CONFIG: Partial = { */ describe("AppConfigService", () => { let service: AppConfigService; + let appConfigSetSpy: jasmine.Spy>; beforeEach(() => { TestBed.configureTestingModule({ @@ -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"); }); @@ -80,7 +73,32 @@ 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); + }); + + 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 all overrides 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"); + }); - service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "partial changes" } }); + it("reverts to lower order config values when higher order override removed", async () => { + service.setAppConfig({ APP_FOOTER_DEFAULTS: { templateName: "skin_footer" } }, "skin"); + expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("skin_footer"); + 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("skin_footer"); }); }); diff --git a/src/app/shared/services/app-config/app-config.service.ts b/src/app/shared/services/app-config/app-config.service.ts index 04c6bd3dfe..a97cd8f01e 100644 --- a/src/app/shared/services/app-config/app-config.service.ts +++ b/src/app/shared/services/app-config/app-config.service.ts @@ -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 priority */ +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(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(undefined); /** Tracking observable of deep changes to app config, exposed in `changes` public method */ private appConfigChanges$: Observable>; + /** Array of all applied config overrides. Array position represents override order (0-3) */ + private configOverrides: IAppConfigOverride[] = []; + /** * @deprecated - prefer use of config signal and computed/effect bindings * @@ -63,19 +67,40 @@ 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 = {}) { - // Ignore case where no overrides provides or overrides already applied - if (Object.keys(overrides).length === 0) return; - const mergedConfig = deepMergeObjects({} as IAppConfig, this.initialConfig, overrides); + public setAppConfig(overrides: IAppConfigOverride = {}, source: IAppConfigOverrideSource) { + // use override source to specify index used in override order + const overrideIndex = APP_CONFIG_OVERRIDE_ORDER[source]; + + if (!overrideIndex && overrideIndex !== 0) + return console.error(`[APP CONFIG] Unknown config override source, ${source}`); + + // ignore updates that are identical to current overrides for a given level + if (isEqual(overrides, this.configOverrides[overrideIndex])) { + return; + } + + // 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); + + // if merged config unchanged ignore (e.g. lower order update superseded by higher order) + if (isEqual(this.appConfig(), mergedConfig)) { + return; + } + + // trigger change effects this.handleConfigSideEffects(overrides, mergedConfig); this.appConfig.set(mergedConfig); this.appConfig$.next(mergedConfig); diff --git a/src/app/shared/services/screen-orientation/screen-orientation.service.ts b/src/app/shared/services/screen-orientation/screen-orientation.service.ts index eb399ebb47..608f164d74 100644 --- a/src/app/shared/services/screen-orientation/screen-orientation.service.ts +++ b/src/app/shared/services/screen-orientation/screen-orientation.service.ts @@ -22,7 +22,7 @@ export class ScreenOrientationService extends SyncServiceBase { private templateActionRegistry: TemplateActionRegistry, private templateMetadataService: TemplateMetadataService ) { - super("Screen Orientation Service"); + super("ScreenOrientation"); // TODO: expose a property at deployment config level to enable "landscape_mode" to avoid unnecessary checks // AND/OR: check on init if any templates actually use screen orientation metadata? diff --git a/src/app/shared/services/skin/skin.service.ts b/src/app/shared/services/skin/skin.service.ts index 3cea4f82d2..d38881da77 100644 --- a/src/app/shared/services/skin/skin.service.ts +++ b/src/app/shared/services/skin/skin.service.ts @@ -12,12 +12,10 @@ import { SyncServiceBase } from "../syncService.base"; providedIn: "root", }) export class SkinService extends SyncServiceBase { + public currentSkinName: string; /** A hashmap of all skins available to the current deployment */ private availableSkins: Record; - /** Track overrides required to undo a previously applied skin (if applying another) */ - private revertOverride: RecursivePartial = {}; - constructor( private appConfigService: AppConfigService, private localStorageService: LocalStorageService, @@ -44,14 +42,16 @@ export class SkinService extends SyncServiceBase { * @param [isInit=false] Whether or not the function is being triggered by the service's initialisation * */ public setSkin(skinName: string, isInit = false) { + // ignore if skin name unchanged + if (skinName === this.currentSkinName) { + return; + } + this.currentSkinName = skinName; + if (this.availableSkins.hasOwnProperty(skinName)) { const targetSkin = this.availableSkins[skinName]; - - const override = this.generateOverrideConfig(targetSkin); - const revert = this.generateRevertConfig(targetSkin); - console.log("[SKIN] SET", { targetSkin, override, revert }); - this.appConfigService.setAppConfig(override); - this.revertOverride = revert; + this.appConfigService.setAppConfig(targetSkin.appConfig, "skin"); + console.log("[SKIN] SET", targetSkin); if (!isInit) { // Update default values when skin changed to allow for skin-specific global overrides @@ -74,31 +74,6 @@ export class SkinService extends SyncServiceBase { return this.localStorageService.getProtected("APP_SKIN"); } - /** - * Skin overrides are designed to be merged on top of the default app config - * When applying a new skin calculate the config changes required to both - * revert any previous skin override and apply new - */ - private generateOverrideConfig(skin: IAppSkin) { - // Merge onto new object to avoid changing stored revertOverride - const base: RecursivePartial = {}; - return deepMergeObjects(base, this.revertOverride, skin.appConfig); - } - - /** Determine config that would need to be applied to revert the new update */ - private generateRevertConfig(skin: IAppSkin) { - const revert: RecursivePartial = {}; - const config = this.appConfigService.appConfig(); - for (const key of Object.keys(skin.appConfig || {})) { - // When reverting the skin, should target the current config value unless - // previously overridden (in which case target initial value) - const revertTarget = deepMergeObjects({}, config[key], this.revertOverride[key]); - // Track what has changed to be able to revert back in future - revert[key] = updatedDiff(skin.appConfig[key], revertTarget); - } - return revert; - } - /** * Load the active app skin. Loads previously stored configuration if available, * with fallback to default app skin