From d50221f5af568292b985d8c86b263de1736e7987 Mon Sep 17 00:00:00 2001 From: Shilo Date: Tue, 22 Jul 2025 15:45:12 +1000 Subject: [PATCH 01/20] Initial Tab placeholder --- .../form/src/app/form.service.ts | 51 ++++++++++++++----- .../src/app/static-comp-field.dictionary.ts | 4 ++ typescript/form-config/default-1.0-draft.ts | 9 ++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/angular/projects/researchdatabox/form/src/app/form.service.ts b/angular/projects/researchdatabox/form/src/app/form.service.ts index 61e6788ea..beb864f39 100644 --- a/angular/projects/researchdatabox/form/src/app/form.service.ts +++ b/angular/projects/researchdatabox/form/src/app/form.service.ts @@ -191,10 +191,10 @@ export class FormService extends HttpClientService { const modelClassName:string = componentConfig.model?.class || ''; let componentClassName:string = componentConfig.component?.class || ''; let layoutClassName:string = componentConfig.layout?.class || ''; - if (_isEmpty(modelClassName)) { - this.loggerService.error(`${this.logName}: model class name is empty for component.`, componentConfig); - continue; - } + // if (_isEmpty(modelClassName)) { + // this.loggerService.error(`${this.logName}: model class name is empty for component.`, componentConfig); + // continue; + // } if (!_isEmpty(componentConfig.module)) { // TODO: // 1. for statically imported (e.g. modules) class doesn't have to be resolved here @@ -231,20 +231,43 @@ export class FormService extends HttpClientService { layoutClass = this.compClassMap[layoutClassName]; } } + let fieldDef = {}; if (modelClass) { - if (componentClass) { - fieldArr.push({ - modelClass: modelClass, - componentClass: componentClass, - compConfigJson: componentConfig, - layoutClass: layoutClass, - } as FormFieldCompMapEntry); - } else { - this.logNotAvailable(componentClassName, "component class", this.compClassMap); - } + _merge(fieldDef, { + modelClass: modelClass, + }); } else { this.logNotAvailable(modelClassName, "model class", this.modelClassMap); } + if (componentClass) { + _merge(fieldDef, { + componentClass: componentClass, + compConfigJson: componentConfig, + layoutClass: layoutClass, + }); + } else { + this.logNotAvailable(componentClassName, "component class", this.compClassMap); + // Dont' add to the array if the component class is not available + fieldDef = {}; + } + // if (modelClass) { + // if (componentClass) { + + // fieldArr.push({ + // modelClass: modelClass, + // componentClass: componentClass, + // compConfigJson: componentConfig, + // layoutClass: layoutClass, + // } as FormFieldCompMapEntry); + // } else { + // this.logNotAvailable(componentClassName, "component class", this.compClassMap); + // } + // } else { + // this.logNotAvailable(modelClassName, "model class", this.modelClassMap); + // } + if (!_isEmpty(fieldDef)) { + fieldArr.push(fieldDef as FormFieldCompMapEntry); + } } this.loggerService.debug(`${this.logName}: resolved form component types:`, fieldArr); return fieldArr; diff --git a/angular/projects/researchdatabox/form/src/app/static-comp-field.dictionary.ts b/angular/projects/researchdatabox/form/src/app/static-comp-field.dictionary.ts index 0c6922079..6a21d6358 100644 --- a/angular/projects/researchdatabox/form/src/app/static-comp-field.dictionary.ts +++ b/angular/projects/researchdatabox/form/src/app/static-comp-field.dictionary.ts @@ -4,6 +4,7 @@ import {DefaultLayoutComponent} from "./component/default-layout.component"; import { each as _each, map as _map, endsWith as _endsWith } from 'lodash-es'; import {ValidationSummaryFieldComponent, ValidationSummaryFieldModel} from "./component/validation-summary.component"; import {GroupFieldModel, GroupFieldComponent } from "./component/groupfield.component"; +import { TabComponent } from "./component/tab.component"; /** Field related */ export interface FormFieldModelClassMap { @@ -38,6 +39,9 @@ export const StaticModelCompClassMap = { model: GroupFieldModel, component: GroupFieldComponent }, + 'TabComponent': { + component: TabComponent + } }; diff --git a/typescript/form-config/default-1.0-draft.ts b/typescript/form-config/default-1.0-draft.ts index f8d226d94..7f5aee8fc 100644 --- a/typescript/form-config/default-1.0-draft.ts +++ b/typescript/form-config/default-1.0-draft.ts @@ -31,6 +31,15 @@ const formConfig: FormConfig = { ], componentDefinitions: [ + { + name: 'main_tab', + component: { + class: 'TabComponent', + config: { + + } + } + }, { name: 'text_1_event', model: { From df4253fb678c2aad87d2cc07cd24a56667cb3db8 Mon Sep 17 00:00:00 2001 From: Shilo Date: Tue, 22 Jul 2025 16:34:54 +1000 Subject: [PATCH 02/20] Added TabComponent definition and model. Models are now optional for component definitions. --- .../form/src/app/component/tab.component.ts | 49 +++++++++++++++++++ .../form/src/app/form.service.ts | 9 ++-- .../src/config/component/index.ts | 9 ++-- .../src/config/component/tab.model.ts | 13 +++++ .../src/config/form-component.model.ts | 2 +- 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 angular/projects/researchdatabox/form/src/app/component/tab.component.ts create mode 100644 packages/sails-ng-common/src/config/component/tab.model.ts diff --git a/angular/projects/researchdatabox/form/src/app/component/tab.component.ts b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts new file mode 100644 index 000000000..12bde6661 --- /dev/null +++ b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts @@ -0,0 +1,49 @@ + + +import { Component } from '@angular/core'; +import { FormFieldBaseComponent } from '@researchdatabox/portal-ng-common'; +import { FormFieldComponentDefinition } from '@researchdatabox/sails-ng-common'; +import { set as _set, isEmpty as _isEmpty, cloneDeep as _cloneDeep, get as _get, isUndefined as _isUndefined, isNull as _isNull } from 'lodash-es'; + +/** + * Repeatable Form Field Component + * + * The layout-specific section is meant to be minimal. + * + * + */ +@Component({ + selector: 'redbox-form-tab', + template:` +
+ +
+
...
+
...
+
...
+
...
+
...
+
+
`, + standalone: false +}) +export class TabComponent extends FormFieldBaseComponent { + + // protected override async initData() { + // await this.untilViewIsInitialised(); + // // + // const tabDef = this.componentDefinition as FormFieldComponentDefinition; + + + // } +} + + + + diff --git a/angular/projects/researchdatabox/form/src/app/form.service.ts b/angular/projects/researchdatabox/form/src/app/form.service.ts index 94764bfb7..57aad190b 100644 --- a/angular/projects/researchdatabox/form/src/app/form.service.ts +++ b/angular/projects/researchdatabox/form/src/app/form.service.ts @@ -306,15 +306,16 @@ export class FormService extends HttpClientService { compMapEntry: FormFieldCompMapEntry, validatorDefinitions: FormValidatorDefinition[] | null | undefined ): FormFieldModel | null { - if (compMapEntry.modelClass) { - const ModelType = compMapEntry.modelClass; - const modelConfig = compMapEntry.compConfigJson.model; + const ModelType = compMapEntry.modelClass; + const modelConfig = compMapEntry.compConfigJson.model; + if (ModelType && modelConfig) { const validatorConfig = modelConfig?.config?.validators ?? []; const validators = this.getValidatorsSupport.createFormValidatorInstances(validatorDefinitions, validatorConfig); compMapEntry.model = new ModelType(modelConfig, validators); return compMapEntry.model; } else { - this.logNotAvailable(compMapEntry.modelClass ?? "(unknown)", "model class", this.modelClassMap); + // Model is now optional, so we can return null if the model is not defined. Add appropriate warning to catch config errors. + this.loggerService.warn(`${this.logName}: Model class or model config is not defined for component. If this is unexpected, check your form configuration.`, compMapEntry); } return null; } diff --git a/packages/sails-ng-common/src/config/component/index.ts b/packages/sails-ng-common/src/config/component/index.ts index 600027ce3..b0d902350 100644 --- a/packages/sails-ng-common/src/config/component/index.ts +++ b/packages/sails-ng-common/src/config/component/index.ts @@ -25,7 +25,7 @@ import { TextFormFieldModelDefinition } from "./text.model"; import {DefaultFormFieldLayoutConfig, DefaultFormFieldLayoutDefinition} from "./default-layout.model"; - +import { TabComponentConfig, TabComponentDefinition } from "./tab.model"; /** * Possible form field component definitions. */ @@ -33,7 +33,8 @@ export type FormFieldComponentDefinition = TextFormFieldComponentDefinition | RepeatableFormFieldComponentDefinition | ValidationSummaryFormFieldComponentDefinition | - GroupFormFieldComponentDefinition; + GroupFormFieldComponentDefinition | + TabComponentDefinition; /** * Possible form field component configs. @@ -42,7 +43,8 @@ export type FormFieldComponentConfig = TextFormFieldComponentConfig | RepeatableFormFieldComponentConfig | ValidationSummaryFormFieldComponentConfig | - GroupFormFieldComponentConfig; + GroupFormFieldComponentConfig | + TabComponentConfig; /** * Possible form field model definitions. @@ -83,3 +85,4 @@ export * from './group.model' export * from './repeatable.model' export * from './text.model' export * from './validation-summary.model' +export * from './tab.model' \ No newline at end of file diff --git a/packages/sails-ng-common/src/config/component/tab.model.ts b/packages/sails-ng-common/src/config/component/tab.model.ts new file mode 100644 index 000000000..d852a41e6 --- /dev/null +++ b/packages/sails-ng-common/src/config/component/tab.model.ts @@ -0,0 +1,13 @@ +import {BaseFormFieldModelConfig, BaseFormFieldModelDefinition} from "../form-field-model.model"; +import {FormComponentDefinition} from "../form-component.model"; +import {BaseFormFieldComponentConfig, BaseFormFieldComponentDefinition} from "../form-field-component.model"; + + +export interface TabComponentDefinition extends BaseFormFieldComponentDefinition { + class: "TabComponent"; + config?: TabComponentConfig; +} + +export class TabComponentConfig extends BaseFormFieldComponentConfig { +} + diff --git a/packages/sails-ng-common/src/config/form-component.model.ts b/packages/sails-ng-common/src/config/form-component.model.ts index e17377774..9ffc9859a 100644 --- a/packages/sails-ng-common/src/config/form-component.model.ts +++ b/packages/sails-ng-common/src/config/form-component.model.ts @@ -13,7 +13,7 @@ export interface FormComponentDefinition { /** * The definition of the model that backs the form field. */ - model: FormFieldModelDefinition; + model?: FormFieldModelDefinition; /** * The definition of the client-side component for the form field. */ From 9f6ba77c7149af8c886905312260efc2a4475d2d Mon Sep 17 00:00:00 2001 From: Shilo Date: Wed, 30 Jul 2025 12:20:10 +1000 Subject: [PATCH 03/20] TabComponent properly enumerates and intialises content's components. Moved the FormComponent's FormGroup creation until after all the components have been intialised. --- .../app/component/base-wrapper.component.ts | 2 +- .../form/src/app/component/tab.component.ts | 222 ++++++- .../form/src/app/form.component.ts | 14 +- .../form/src/app/form.service.ts | 10 + .../src/lib/form/form-field-base.component.ts | 2 + .../src/config/component/index.ts | 5 +- .../src/config/component/tab.model.ts | 18 +- typescript/form-config/default-1.0-draft.ts | 629 +++++++++--------- 8 files changed, 557 insertions(+), 345 deletions(-) diff --git a/angular/projects/researchdatabox/form/src/app/component/base-wrapper.component.ts b/angular/projects/researchdatabox/form/src/app/component/base-wrapper.component.ts index fb4e09805..e62ebb5a0 100644 --- a/angular/projects/researchdatabox/form/src/app/component/base-wrapper.component.ts +++ b/angular/projects/researchdatabox/form/src/app/component/base-wrapper.component.ts @@ -41,7 +41,7 @@ export class FormBaseWrapperComponent extends FormFieldBaseComponent< @ViewChild(FormBaseWrapperDirective, {static: true}) formFieldDirective!: FormBaseWrapperDirective; - protected get componentRef() { + public get componentRef() { return this.formFieldCompMapEntry?.layoutRef || this.formFieldCompMapEntry?.componentRef || null; } diff --git a/angular/projects/researchdatabox/form/src/app/component/tab.component.ts b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts index 12bde6661..7dea1df57 100644 --- a/angular/projects/researchdatabox/form/src/app/component/tab.component.ts +++ b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts @@ -1,9 +1,11 @@ - - -import { Component } from '@angular/core'; -import { FormFieldBaseComponent } from '@researchdatabox/portal-ng-common'; -import { FormFieldComponentDefinition } from '@researchdatabox/sails-ng-common'; -import { set as _set, isEmpty as _isEmpty, cloneDeep as _cloneDeep, get as _get, isUndefined as _isUndefined, isNull as _isNull } from 'lodash-es'; +import { Component, ViewChild, ViewContainerRef, TemplateRef, signal, input, Input, ViewChildren, ElementRef, QueryList, Directive, ContentChildren, contentChildren, computed, ComponentRef, inject, Injector } from '@angular/core'; +import { FormFieldBaseComponent, FormFieldCompMapEntry } from '@researchdatabox/portal-ng-common'; +import { FormConfig, TabComponentEntryDefinition, TabComponentConfig, TabContentComponentConfig } from '@researchdatabox/sails-ng-common'; +import { set as _set, isEmpty as _isEmpty, cloneDeep as _cloneDeep, get as _get, isUndefined as _isUndefined, isNull as _isNull, find as _find, merge as _merge } from 'lodash-es'; +import { FormComponent } from "../form.component"; +import { FormBaseWrapperComponent } from './base-wrapper.component'; +import { TabsetComponent } from 'ngx-bootstrap/tabs'; +import { FormComponentsMap, FormService } from '../form.service'; /** * Repeatable Form Field Component @@ -16,32 +18,198 @@ import { set as _set, isEmpty as _isEmpty, cloneDeep as _cloneDeep, get as _get, selector: 'redbox-form-tab', template:`
- -
-
...
-
...
-
...
-
...
-
...
+ + + +
+ +
-
`, +`, standalone: false }) -export class TabComponent extends FormFieldBaseComponent { - - // protected override async initData() { - // await this.untilViewIsInitialised(); - // // - // const tabDef = this.componentDefinition as FormFieldComponentDefinition; +export class TabComponent extends FormFieldBaseComponent { + protected override logName: string | null = "TabComponent"; + tabs: TabComponentEntryDefinition[] = []; + selectedTabId: string | null = null; + wrapperRefs: ComponentRef>[] = []; + componentInstances: any[] = []; + componentFormMapEntries: FormFieldCompMapEntry[] = []; + @ViewChild('tabsContainer', { read: ViewContainerRef, static: true }) private tabsContainer!: ViewContainerRef; + + protected override async initData() { + this.tabs = (this.componentDefinition?.config as TabComponentConfig)?.tabs || []; + } + + protected override async setComponentReady(): Promise { + await this.untilViewIsInitialised(); + this.loggerService.info(`${this.logName}: Initializing TabComponent with ${this.tabs.length} tabs.`); + this.tabsContainer.clear(); + + for (let i = 0; i < this.tabs.length; i++) { + const tab = this.tabs[i]; + const tabWrapperRef = this.tabsContainer.createComponent(FormBaseWrapperComponent); + + const fieldMapDefEntry = { + componentClass: TabContentComponent, + compConfigJson: { + name: tab.id, + component: { + class: 'TabContentComponent', + config: { + tab: tab, + wrapperCssClasses: 'tab-pane fade' + } + } + } + } as FormFieldCompMapEntry; + if (i === 0) { + _set(fieldMapDefEntry, 'compConfigJson.component.config.wrapperCssClasses', 'tab-pane fade show active'); + } + console.log(fieldMapDefEntry); + // tabWrapperRef.instance.tab = tab; + try { + await tabWrapperRef.instance.initWrapperComponent(fieldMapDefEntry, false); + } catch (error) { + this.loggerService.error(`${this.logName}: Error initializing tab wrapper component`, error); + } + console.log(fieldMapDefEntry); + this.componentFormMapEntries.push(fieldMapDefEntry); + this.wrapperRefs.push(tabWrapperRef); + // append the tab's content pane together + this.componentInstances.push(...fieldMapDefEntry.component?.getComponents() || []); + // Merge the tab content `formControlMap` into the main model map + if (fieldMapDefEntry.formControlMap && this.formFieldCompMapEntry != null) { + if (this.formFieldCompMapEntry?.formControlMap == null) { + this.formFieldCompMapEntry.formControlMap = {}; + } + _merge(this.formFieldCompMapEntry.formControlMap, fieldMapDefEntry.formControlMap); + } + } + await super.setComponentReady(); + } + + selectTab(tabId: string) { + this.loggerService.info(`${this.logName}: Selecting tab with ID: ${tabId}`); + if (tabId === this.selectedTabId) { + this.loggerService.warn(`${this.logName}: Tab with ID ${tabId} is already selected.`); + return; + } + // Peek ahead if the tab exists + const wrapperInst = _find(this.wrapperRefs, (ref: ComponentRef>) => { + return ref.instance.formFieldCompMapEntry?.compConfigJson?.name === tabId; + }); + if (!wrapperInst) { + this.loggerService.warn(`${this.logName}: Wrapper instance not found for tab ID: ${tabId}`); + return; + } + // remove the 'show active' classes from all tabs + this.wrapperRefs.forEach((ref: ComponentRef>) => { + const instance = ref.instance; + if (instance.formFieldCompMapEntry?.compConfigJson?.name == tabId) { + instance.hostBindingCssClasses = `${instance.hostBindingCssClasses} show active`; + this.selectedTabId = tabId; + } else { + instance.hostBindingCssClasses = instance.hostBindingCssClasses?.replace('show active', ''); + } + }); + } + + public override getComponents(): any[] { + return this.componentInstances; + } + + public get components(): FormFieldCompMapEntry[] { + return this.componentFormMapEntries; + } +} + +@Component({ + selector: 'redbox-form-tab-content', + template: ``, + standalone: false, +}) +export class TabContentComponent extends FormFieldBaseComponent { + protected override logName: string | null = "TabContentComponent"; + tab?: TabComponentEntryDefinition; + @ViewChild('componentContainer', { + read: ViewContainerRef, + static: false + }) + componentsDefinitionsContainerRef?: ViewContainerRef; + protected formService = inject(FormService); + private injector = inject(Injector); + componentRefs: ComponentRef>[] = []; + private componentInstances: any[] = []; + protected formDefMap?: FormComponentsMap; + + protected override async initData() { + this.tab = (this.componentDefinition?.config as TabContentComponentConfig)?.tab; + if (!this.tab) { + this.loggerService.error(`${this.logName}: No tab defined in component configuration.`); + } + } + + protected override async setComponentReady(): Promise { + await this.untilViewIsInitialised(); + if (this.componentsDefinitionsContainerRef == null || this.componentsDefinitionsContainerRef == undefined) { + throw new Error(`${this.logName}: componentsDefinitionsContainer is not defined.`); + } + const formConfig = this.formComponentRef.formDefMap?.formConfig; + const compFormConfig: FormConfig = { + componentDefinitions: this.tab?.componentDefinitions || [], + defaultComponentConfig: formConfig?.defaultComponentConfig, + // Get the validator definitions so the child components can use them. + validatorDefinitions: formConfig?.validatorDefinitions ?? [], + }; - // } + this.formDefMap = await this.formService.createFormComponentsMap(compFormConfig); + if (this.formDefMap != null && this.formDefMap != undefined) { + for (const formFieldDef of this.formDefMap.components) { + const componentRef = this.componentsDefinitionsContainerRef.createComponent(FormBaseWrapperComponent); + await componentRef.instance.initWrapperComponent(formFieldDef); + this.componentRefs.push(componentRef); + this.componentInstances.push(componentRef.instance?.componentRef?.instance); + } + + const groupedByNameMap = this.formService.groupComponentsByName(this.formDefMap); + if (this.formFieldCompMapEntry != null && this.formFieldCompMapEntry != undefined) { + // Populate the `formControlMap` with the models of the created components + this.formFieldCompMapEntry.formControlMap = groupedByNameMap.withFormControl; + } + } + + this.loggerService.info(`${this.logName}: TabContentComponent is ready for tab: ${this.tab?.id}.`); + await super.setComponentReady(); + } + + protected get formComponentRef(): FormComponent { + return this.injector.get(FormComponent); + } + + public override getComponents(): any[] { + return this.componentInstances; + } + + public get components(): FormFieldCompMapEntry[] { + return this.formDefMap?.components || []; + } } diff --git a/angular/projects/researchdatabox/form/src/app/form.component.ts b/angular/projects/researchdatabox/form/src/app/form.component.ts index 6749dacd6..2e3844c93 100644 --- a/angular/projects/researchdatabox/form/src/app/form.component.ts +++ b/angular/projects/researchdatabox/form/src/app/form.component.ts @@ -125,9 +125,8 @@ export class FormComponent extends BaseComponent { this.loggerService.log(`${this.logName}: creating form definition from provided config`); this.formDefMap = await this.formService.createFormComponentsMap(formConfig); } - this.createFormGroup(); + this.componentDefArr = this.formDefMap.components; const compContainerRef: ViewContainerRef | undefined = this.componentsContainer; - // const compContainerRef:ViewContainerRef | undefined = this.componentsContainer(); if (!compContainerRef) { this.loggerService.error(`${this.logName}: No component container found. Cannot load components.`); throw new Error(`${this.logName}: No component container found. Cannot load components.`); @@ -140,8 +139,8 @@ export class FormComponent extends BaseComponent { await componentRef.instance.initWrapperComponent(componentDefEntry); this.loggerService.info(`FormComponent: downloadAndCreateFormComponents: `, componentDefEntry.component); } - // TODO: set up the event handlers - + // Moved the creation of the FormGroup to after all components are created, allows for components that have custom management of their children components. + this.createFormGroup(); // Set the status to READY if all components are loaded this.status.set(FormStatus.READY); this.componentsLoaded.set(true); @@ -164,10 +163,7 @@ export class FormComponent extends BaseComponent { const validatorDefinitions = this.formDefMap.formConfig.validatorDefinitions; const validatorConfig = this.formDefMap.formConfig.validators; const validators = this.formService.getValidatorsSupport.createFormValidatorInstances(validatorDefinitions, validatorConfig); - this.formService.setValidators(this.form, validators); - - // setting this will trigger the form to be rendered - this.componentDefArr = components; + this.formService.setValidators(this.form, validators); } else { const msg = `No form controls found in the form definition. Form cannot be rendered.`; this.loggerService.error(`${this.logName}: ${msg}`); @@ -250,7 +246,7 @@ export class FormComponent extends BaseComponent { viewInitialised: componentEntry?.component?.viewInitialised(), }; - if (["RepeatableComponent", "GroupFieldComponent"].includes(componentConfigClassName)) { + if (["RepeatableComponent", "GroupFieldComponent", "TabComponent", "TabContentComponent"].includes(componentConfigClassName)) { // TODO: can this be improved? The check on the class name helps avoid issues, but 'any' type is still not great. const component = formFieldCompMapEntry?.component as any; componentResult.children = component?.components?.map((i: FormFieldCompMapEntry) => this.getComponentDebugInfo(i)); diff --git a/angular/projects/researchdatabox/form/src/app/form.service.ts b/angular/projects/researchdatabox/form/src/app/form.service.ts index 57aad190b..0e3613859 100644 --- a/angular/projects/researchdatabox/form/src/app/form.service.ts +++ b/angular/projects/researchdatabox/form/src/app/form.service.ts @@ -345,6 +345,16 @@ export class FormService extends HttpClientService { if (formControl && fieldName) { groupWithFormControl[fieldName] = formControl; } + } else { + // Some components may not have a model themselves, but can 'contain' other components + if (compEntry.formControlMap && !_isEmpty(compEntry.formControlMap)) { + // traverse the model map and add the models to the group map + for (const [name, formControl] of Object.entries(compEntry.formControlMap)) { + if (formControl && name) { + groupWithFormControl[name] = formControl; + } + } + } } } compMap.completeGroupMap = groupMap; diff --git a/angular/projects/researchdatabox/portal-ng-common/src/lib/form/form-field-base.component.ts b/angular/projects/researchdatabox/portal-ng-common/src/lib/form/form-field-base.component.ts index fd768d6ff..efecb56e6 100644 --- a/angular/projects/researchdatabox/portal-ng-common/src/lib/form/form-field-base.component.ts +++ b/angular/projects/researchdatabox/portal-ng-common/src/lib/form/form-field-base.component.ts @@ -527,4 +527,6 @@ export interface FormFieldCompMapEntry { layout?: FormFieldBaseComponent; layoutRef?: ComponentRef>; componentTemplateRefMap? : { [key: string]: TemplateRef }; + // optional control map to support 'container' like components that don't have a model themselves + formControlMap?: { [key: string]: FormControl }; } diff --git a/packages/sails-ng-common/src/config/component/index.ts b/packages/sails-ng-common/src/config/component/index.ts index b0d902350..40dd87596 100644 --- a/packages/sails-ng-common/src/config/component/index.ts +++ b/packages/sails-ng-common/src/config/component/index.ts @@ -25,7 +25,7 @@ import { TextFormFieldModelDefinition } from "./text.model"; import {DefaultFormFieldLayoutConfig, DefaultFormFieldLayoutDefinition} from "./default-layout.model"; -import { TabComponentConfig, TabComponentDefinition } from "./tab.model"; +import {TabComponentConfig, TabFormFieldComponentDefinition, TabContentComponentDefinition} from "./tab.model"; /** * Possible form field component definitions. */ @@ -34,7 +34,8 @@ export type FormFieldComponentDefinition = RepeatableFormFieldComponentDefinition | ValidationSummaryFormFieldComponentDefinition | GroupFormFieldComponentDefinition | - TabComponentDefinition; + TabFormFieldComponentDefinition | + TabContentComponentDefinition; /** * Possible form field component configs. diff --git a/packages/sails-ng-common/src/config/component/tab.model.ts b/packages/sails-ng-common/src/config/component/tab.model.ts index d852a41e6..17543db6f 100644 --- a/packages/sails-ng-common/src/config/component/tab.model.ts +++ b/packages/sails-ng-common/src/config/component/tab.model.ts @@ -1,13 +1,27 @@ -import {BaseFormFieldModelConfig, BaseFormFieldModelDefinition} from "../form-field-model.model"; import {FormComponentDefinition} from "../form-component.model"; import {BaseFormFieldComponentConfig, BaseFormFieldComponentDefinition} from "../form-field-component.model"; -export interface TabComponentDefinition extends BaseFormFieldComponentDefinition { +export interface TabFormFieldComponentDefinition extends BaseFormFieldComponentDefinition { class: "TabComponent"; config?: TabComponentConfig; } +export interface TabComponentEntryDefinition { + id: string; // internal identifier for the tab + buttonLabel: string; // The text on the button + componentDefinitions: FormComponentDefinition[]; // The components to render in the tab +} + export class TabComponentConfig extends BaseFormFieldComponentConfig { + tabs?: TabComponentEntryDefinition[]; +} + +export interface TabContentComponentDefinition extends BaseFormFieldComponentDefinition { + class: "TabContentComponent"; + config?: TabContentComponentConfig; } +export class TabContentComponentConfig extends BaseFormFieldComponentConfig { + tab?: TabComponentEntryDefinition; +} \ No newline at end of file diff --git a/typescript/form-config/default-1.0-draft.ts b/typescript/form-config/default-1.0-draft.ts index 8501a544d..25db8c11f 100644 --- a/typescript/form-config/default-1.0-draft.ts +++ b/typescript/form-config/default-1.0-draft.ts @@ -37,318 +37,332 @@ const formConfig: FormConfig = { component: { class: 'TabComponent', config: { - - } - } - }, - { - name: 'text_1_event', - model: { - class: 'TextFieldModel', - config: { - value: 'hello world!', - defaultValue: 'hello world!', - validators: [ - {name: 'required'}, - ] - } - }, - component: { - class: 'TextFieldComponent' - } - }, - { - name: 'text_2', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'TextField with default wrapper defined', - helpText: 'This is a help text', - } - }, - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 2!', - validators: [ - {name: 'pattern', config: {pattern: /prefix.*/, description: "must start with prefix"}}, - {name: 'minLength', message: "@validator-error-custom-text_2", config: {minLength: 3}}, - ] - } - }, - component: { - class: 'TextFieldComponent' - }, - expressions: { - 'model.value': { - template: `<%= _.get(model,'text_1_event','') %>` - } - } - }, - { - name: 'text_2_event', - model: { - class: 'TextFieldModel', - config: { - value: 'hello world! component event', - defaultValue: 'hello world! component event', - validators: [ - {name: 'required'}, - ] - } - }, - component: { - class: 'TextFieldComponent', - config: { - tooltip: 'text_2_event tooltip' - } - } - }, - { - name: 'text_2_component_event', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'TextField with default wrapper defined', - helpText: 'This is a help text', - tooltip: 'text_2_component_event layout tooltip' - } - }, - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 2! component expression' - } - }, - component: { - class: 'TextFieldComponent', - config: { - tooltip: 'text_2_component_event component tooltip 22222' - } - }, - expressions: { - 'component.visible': { - template: `<% if(_.isEmpty(_.get(model,'text_2_event',''))) { - return false; - } else { - return true; - } %>` - } - } - }, - { - name: 'text_3_event', - model: { - class: 'TextFieldModel', - config: { - value: 'hello world! layout event', - defaultValue: 'hello world! layout event', - validators: [ - {name: 'required'}, - ] - } - }, - component: { - class: 'TextFieldComponent' - } - }, - { - name: 'text_3_layout_event', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'TextField with default wrapper defined', - helpText: 'This is a help text', - } - }, - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 2! layout expression' - } - }, - component: { - class: 'TextFieldComponent' - }, - expressions: { - 'layout.visible': { - template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { - return false; - } else { - return true; - } %>` - } - } - }, - { - // first group component - name: 'group_1_component', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'GroupField label', - helpText: 'GroupField help', - } - }, - model: { - class: 'GroupFieldModel', - config: { - defaultValue: {}, - } - }, - component: { - class: 'GroupFieldComponent', - config: { - componentDefinitions: [ - { - name: 'text_3', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'TextField with default wrapper defined', - helpText: 'This is a help text', - } - }, - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 3!', - } - }, - component: { - class: 'TextFieldComponent' - } - }, + tabs: [ { - name: 'text_4', - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 4!', - defaultValue: 'hello world 4!' - } - }, - component: { - class: 'TextFieldComponent' - } + id: 'tab_1', + buttonLabel: 'Tab 1', + componentDefinitions: [ + { + name: 'text_1_event', + model: { + class: 'TextFieldModel', + config: { + value: 'hello world!', + defaultValue: 'hello world!', + validators: [ + {name: 'required'}, + ] + } + }, + component: { + class: 'TextFieldComponent' + } + }, + { + name: 'text_2', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'TextField with default wrapper defined', + helpText: 'This is a help text', + } + }, + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 2!', + validators: [ + {name: 'pattern', config: {pattern: /prefix.*/, description: "must start with prefix"}}, + {name: 'minLength', message: "@validator-error-custom-text_2", config: {minLength: 3}}, + ] + } + }, + component: { + class: 'TextFieldComponent' + }, + expressions: { + 'model.value': { + template: `<%= _.get(model,'text_1_event','') %>` + } + } + }, + { + name: 'text_2_event', + model: { + class: 'TextFieldModel', + config: { + value: 'hello world! component event', + defaultValue: 'hello world! component event', + validators: [ + {name: 'required'}, + ] + } + }, + component: { + class: 'TextFieldComponent', + config: { + tooltip: 'text_2_event tooltip' + } + } + }, + { + name: 'text_2_component_event', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'TextField with default wrapper defined', + helpText: 'This is a help text', + tooltip: 'text_2_component_event layout tooltip' + } + }, + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 2! component expression' + } + }, + component: { + class: 'TextFieldComponent', + config: { + tooltip: 'text_2_component_event component tooltip 22222' + } + }, + expressions: { + 'component.visible': { + template: `<% if(_.isEmpty(_.get(model,'text_2_event',''))) { + return false; + } else { + return true; + } %>` + } + } + }, + { + name: 'text_3_event', + model: { + class: 'TextFieldModel', + config: { + value: 'hello world! layout event', + defaultValue: 'hello world! layout event', + validators: [ + {name: 'required'}, + ] + } + }, + component: { + class: 'TextFieldComponent' + } + }, + { + name: 'text_3_layout_event', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'TextField with default wrapper defined', + helpText: 'This is a help text', + } + }, + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 2! layout expression' + } + }, + component: { + class: 'TextFieldComponent' + }, + expressions: { + 'layout.visible': { + template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { + return false; + } else { + return true; + } %>` + } + } + }, + ] }, { - // second group component, nested in first group component - name: 'group_2_component', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'GroupField 2 label', - helpText: 'GroupField 2 help', - } - }, - model: { - class: 'GroupFieldModel', - config: { - defaultValue: {}, - } - }, - component: { - class: 'GroupFieldComponent', - config: { - componentDefinitions: [ - { - name: 'text_5', - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'TextField with default wrapper defined', - helpText: 'This is a help text', - } - }, - model: { - class: 'TextFieldModel', - config: { - value: 'hello world 5!', + id: 'tab_2', + buttonLabel: 'Tab 2', + componentDefinitions: [ + { + // first group component + name: 'group_1_component', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'GroupField label', + helpText: 'GroupField help', + } + }, + model: { + class: 'GroupFieldModel', + config: { + defaultValue: {}, + } + }, + component: { + class: 'GroupFieldComponent', + config: { + componentDefinitions: [ + { + name: 'text_3', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'TextField with default wrapper defined', + helpText: 'This is a help text', + } + }, + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 3!', + } + }, + component: { + class: 'TextFieldComponent' + } + }, + { + name: 'text_4', + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 4!', + defaultValue: 'hello world 4!' + } + }, + component: { + class: 'TextFieldComponent' + } + }, + { + // second group component, nested in first group component + name: 'group_2_component', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'GroupField 2 label', + helpText: 'GroupField 2 help', + } + }, + model: { + class: 'GroupFieldModel', + config: { + defaultValue: {}, + } + }, + component: { + class: 'GroupFieldComponent', + config: { + componentDefinitions: [ + { + name: 'text_5', + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'TextField with default wrapper defined', + helpText: 'This is a help text', + } + }, + model: { + class: 'TextFieldModel', + config: { + value: 'hello world 5!', + } + }, + component: { + class: 'TextFieldComponent' + } + } + ] + } + } } + ] + } + }, + expressions: { + 'layout.visible': { + template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { + return false; + } else { + return true; + } %>` + } + } + }, + { + name: 'repeatable_textfield_1', + model: { + class: 'RepeatableComponentModel', + config: { + value: ['hello world from repeatable value!'], + defaultValue: ['hello world from repeatable, default!'] + } + }, + component: { + class: 'RepeatableComponent', + config: { + elementTemplate: { + name: 'example_repeatable', + model: { + class: 'TextFieldModel', + config: { + defaultValue: 'hello world from elementTemplate!', + validators: [ + { + name: 'pattern', + config: {pattern: /prefix.*/, description: "must start with prefix"} + }, + { + name: 'minLength', + message: "@validator-error-custom-text_2", + config: {minLength: 3} + }, + ] + } + }, + component: { + class: 'TextFieldComponent', + config: { + wrapperCssClasses: 'col', + } + }, + layout: { + class: 'RepeatableElementLayoutComponent', + config: { + hostCssClasses: 'row align-items-start' + } + }, }, - component: { - class: 'TextFieldComponent' - } + }, + }, + layout: { + class: 'DefaultLayoutComponent', + config: { + label: 'Repeatable TextField with default wrapper defined', + helpText: 'Repeatable component help text', } - ] - } - } + }, + expressions: { + 'layout.visible': { + template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { + return false; + } else { + return true; + } %>` + } + } + }, + + ] } ] } - }, - expressions: { - 'layout.visible': { - template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { - return false; - } else { - return true; - } %>` - } - } - }, - { - name: 'repeatable_textfield_1', - model: { - class: 'RepeatableComponentModel', - config: { - value: ['hello world from repeatable value!'], - defaultValue: ['hello world from repeatable, default!'] - } - }, - component: { - class: 'RepeatableComponent', - config: { - elementTemplate: { - name: 'example_repeatable', - model: { - class: 'TextFieldModel', - config: { - defaultValue: 'hello world from elementTemplate!', - validators: [ - { - name: 'pattern', - config: {pattern: /prefix.*/, description: "must start with prefix"} - }, - { - name: 'minLength', - message: "@validator-error-custom-text_2", - config: {minLength: 3} - }, - ] - } - }, - component: { - class: 'TextFieldComponent', - config: { - wrapperCssClasses: 'col', - } - }, - layout: { - class: 'RepeatableElementLayoutComponent', - config: { - hostCssClasses: 'row align-items-start' - } - }, - }, - }, - }, - layout: { - class: 'DefaultLayoutComponent', - config: { - label: 'Repeatable TextField with default wrapper defined', - helpText: 'Repeatable component help text', - } - }, - expressions: { - 'layout.visible': { - template: `<% if(_.isEmpty(_.get(model,'text_3_event',''))) { - return false; - } else { - return true; - } %>` - } } }, { @@ -384,6 +398,13 @@ const formConfig: FormConfig = { class: 'TextFieldModel', config: { value: 'hello world 3!', + validators: [ + { + name: 'minLength', + message: "@validator-error-custom-text_2", + config: {minLength: 3} + } + ] } }, component: { @@ -407,7 +428,7 @@ const formConfig: FormConfig = { layout: { class: 'DefaultLayoutComponent', config: { - label: 'Repeatable TextField with default wrapper defined', + label: 'Repeatable TextField not inside the tab with default wrapper defined', helpText: 'Repeatable component help text', } }, From c040336b1a007a5d2336db2a12a85c588a30ee43 Mon Sep 17 00:00:00 2001 From: Shilo Date: Wed, 30 Jul 2025 12:24:45 +1000 Subject: [PATCH 04/20] Refactor getDebugInfo to handle components generically instead of hardcoding specific types. --- .../projects/researchdatabox/form/src/app/form.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/angular/projects/researchdatabox/form/src/app/form.component.ts b/angular/projects/researchdatabox/form/src/app/form.component.ts index 2e3844c93..03e2b43f7 100644 --- a/angular/projects/researchdatabox/form/src/app/form.component.ts +++ b/angular/projects/researchdatabox/form/src/app/form.component.ts @@ -246,9 +246,9 @@ export class FormComponent extends BaseComponent { viewInitialised: componentEntry?.component?.viewInitialised(), }; - if (["RepeatableComponent", "GroupFieldComponent", "TabComponent", "TabContentComponent"].includes(componentConfigClassName)) { - // TODO: can this be improved? The check on the class name helps avoid issues, but 'any' type is still not great. - const component = formFieldCompMapEntry?.component as any; + // If the component has children components, recursively get their debug info. This used to be hardcoded for specific component types, but now it is generic. + const component = formFieldCompMapEntry?.component as any; + if (!_isEmpty(component?.components)) { componentResult.children = component?.components?.map((i: FormFieldCompMapEntry) => this.getComponentDebugInfo(i)); } From 93481a743770ec9b67d11e093bfd429de6efe971 Mon Sep 17 00:00:00 2001 From: Shilo Date: Wed, 30 Jul 2025 16:03:55 +1000 Subject: [PATCH 05/20] Enhance TabComponent with configurable CSS classes and active state for tabs --- .../form/src/app/component/tab.component.ts | 68 ++++++++++++------- assets/styles/default-theme.scss | 4 +- .../src/config/component/tab.model.ts | 6 ++ typescript/form-config/default-1.0-draft.ts | 6 ++ 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/angular/projects/researchdatabox/form/src/app/component/tab.component.ts b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts index 7dea1df57..545c9be98 100644 --- a/angular/projects/researchdatabox/form/src/app/component/tab.component.ts +++ b/angular/projects/researchdatabox/form/src/app/component/tab.component.ts @@ -1,12 +1,10 @@ -import { Component, ViewChild, ViewContainerRef, TemplateRef, signal, input, Input, ViewChildren, ElementRef, QueryList, Directive, ContentChildren, contentChildren, computed, ComponentRef, inject, Injector } from '@angular/core'; +import { Component, ViewChild, ViewContainerRef, ComponentRef, inject, Injector, HostBinding } from '@angular/core'; import { FormFieldBaseComponent, FormFieldCompMapEntry } from '@researchdatabox/portal-ng-common'; import { FormConfig, TabComponentEntryDefinition, TabComponentConfig, TabContentComponentConfig } from '@researchdatabox/sails-ng-common'; import { set as _set, isEmpty as _isEmpty, cloneDeep as _cloneDeep, get as _get, isUndefined as _isUndefined, isNull as _isNull, find as _find, merge as _merge } from 'lodash-es'; import { FormComponent } from "../form.component"; import { FormBaseWrapperComponent } from './base-wrapper.component'; -import { TabsetComponent } from 'ngx-bootstrap/tabs'; import { FormComponentsMap, FormService } from '../form.service'; - /** * Repeatable Form Field Component * @@ -17,26 +15,23 @@ import { FormComponentsMap, FormService } from '../form.service'; @Component({ selector: 'redbox-form-tab', template:` -
+
-