diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f64fbce2..a63b313c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -72,3 +72,5 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 +# with: +# tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.22.0/codeql-bundle-linux64.tar.gz diff --git a/karma.conf.js b/karma.conf.js index ab4aef95..b8758a81 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -25,9 +25,13 @@ module.exports = function (config) { suppressAll: true, // removes the duplicated traces }, coverageReporter: { - dir: require("path").join(__dirname, "./workdocs/reports/coverage"), + dir: require("path").join(__dirname, "workdocs", "reports", "coverage"), subdir: ".", - reporters: [{ type: "html" }, { type: "text-summary" }], + reporters: [ + { type: 'html' }, + { type: 'text-summary' }, + { type: 'lcovonly' } + ], }, reporters: ["progress", "kjhtml"], colors: true, diff --git a/package-lock.json b/package-lock.json index df78458e..f9456840 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3393,9 +3393,9 @@ } }, "node_modules/@decaf-ts/ui-decorators": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@decaf-ts/ui-decorators/-/ui-decorators-0.5.11.tgz", - "integrity": "sha512-9BRXrw3l8A5HMkG0PIe9YeDZzKsj6GGEphVParrwYJpWQ1QKZTAnRqgjmt0vkUcD6VooE5OoXL3iX+SGaENlIQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@decaf-ts/ui-decorators/-/ui-decorators-0.5.14.tgz", + "integrity": "sha512-DoGbVPtnvONjWCwv9dHQqMVSag01L+L/e+HS6jToVpiQSvDXT9E6TXre6nc6ZS4etpCjDRpiM5TrHhdEaL5GFg==", "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index da0ed27f..055a0eac 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:services": "ng test --include='./src/lib/services/*.service.spec.ts' --no-watch --browsers=ChromeHeadlessCI", "lint": "ng lint for-angular", "lint-fix": "ng lint --fix for-angular", - "coverage": "rimraf ./workdocs/reports/data/*.json && npm run test:all -- --coverage --config=./workdocs/reports/jest.coverage.config.ts", + "coverage": "rimraf ./workdocs/reports/data/*.json && npm run test:all -- --code-coverage", "prepare-release": "npm run lint-fix && npm run build:prod && npm run coverage && npm run docs", "release": "./bin/tag-release.sh", "clean-publish": "npx clean-publish", diff --git a/src/app/models/DemoModel.ts b/src/app/models/DemoModel.ts index c7207780..916a939a 100644 --- a/src/app/models/DemoModel.ts +++ b/src/app/models/DemoModel.ts @@ -13,7 +13,7 @@ import { required, url, } from '@decaf-ts/decorator-validation'; -import { uielement, uimodel, uiprop } from '@decaf-ts/ui-decorators'; +import { uichild, uielement, uimodel } from '@decaf-ts/ui-decorators'; import { CategoryModel } from './CategoryModel'; import { UserModel } from './UserModel'; @@ -51,7 +51,7 @@ export class ForAngularModel extends Model { }) gender!: string; - @uiprop(CategoryModel.name) + @uichild(CategoryModel.name, 'ngx-decaf-fieldset') category!: CategoryModel; @required() @@ -73,11 +73,11 @@ export class ForAngularModel extends Model { @required() @password() - @eq("user.passwordRepeat") + @eq('user.secret') @uielement('ngx-decaf-crud-field', { label: 'demo.password.label' }) password!: string; - @uiprop(UserModel.name) + @uichild(UserModel.name, 'ngx-decaf-fieldset') user!: UserModel; @required() diff --git a/src/app/models/UserModel.ts b/src/app/models/UserModel.ts index 253914e3..376d5abd 100644 --- a/src/app/models/UserModel.ts +++ b/src/app/models/UserModel.ts @@ -1,30 +1,23 @@ -import { - eq, - Model, - model, - ModelArg, password, - required, -} from '@decaf-ts/decorator-validation'; -import {uielement, uilistitem, uimodel } from '@decaf-ts/ui-decorators'; +import { eq, Model, model, ModelArg, password, required } from '@decaf-ts/decorator-validation'; +import { uielement, uilistitem, uimodel } from '@decaf-ts/ui-decorators'; -@uilistitem('ngx-decaf-list-item', {icon: 'cafe-outline'}) +@uilistitem('ngx-decaf-list-item', { icon: 'cafe-outline' }) @uimodel('ngx-decaf-crud-form') @model() export class UserModel extends Model { - @required() - @password() - @eq("../password") - @uielement('ngx-decaf-crud-field', { label: 'user.passwordRepeat.label' }) - passwordRepeat!: string; - @required() @uielement('ngx-decaf-crud-field', { - label: 'user.username.label', - placeholder: 'user.username.placeholder', + label: 'user.username.label' }) username!: string; + @required() + @password() + @eq('../password') + @uielement('ngx-decaf-crud-field', { label: 'user.secret.label' }) + secret!: string; + constructor(args: ModelArg = {}) { super(args); } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index dcd0850e..d5bd7587 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -66,16 +66,30 @@ } }, "category": { - "name": {"label": "Category name"}, - "description": {"label": "Category Description"} + "name": { + "label": "Category name", + "placeholder": "Type the category" + }, + "description": { + "label": "Category Description", + "placeholder": "Describe the category (optional)" + } }, "employee": { - "name": {"label": "Employee name"}, - "occupation": {"label": "Employee occupation"} + "name": { + "label": "Employee name" + }, + "occupation": { + "label": "Employee occupation" + } }, "user": { - "passwordRepeat": {"label": "Repeat password"}, - "username": {"label": "Username"} + "secret": { + "label": "Password" + }, + "username": { + "label": "Username" + } }, "component": { "list": { diff --git a/src/lib/components/component-renderer/component-renderer.component.html b/src/lib/components/component-renderer/component-renderer.component.html index 219653d3..5eb38f93 100644 --- a/src/lib/components/component-renderer/component-renderer.component.html +++ b/src/lib/components/component-renderer/component-renderer.component.html @@ -1 +1,22 @@ + + + @if(parent?.children?.length) { + @for(child of parent.children; track child) { + @if(!child.children?.length) { + + } @else { + + } + } + } + + + diff --git a/src/lib/components/component-renderer/component-renderer.component.ts b/src/lib/components/component-renderer/component-renderer.component.ts index 1459d9ef..06292357 100644 --- a/src/lib/components/component-renderer/component-renderer.component.ts +++ b/src/lib/components/component-renderer/component-renderer.component.ts @@ -13,16 +13,13 @@ import { Output, reflectComponentType, SimpleChanges, + TemplateRef, Type, ViewChild, ViewContainerRef, } from '@angular/core'; import { NgxRenderingEngine2 } from 'src/lib/engine/NgxRenderingEngine2'; -import { - BaseCustomEvent, - KeyValue, - ModelRenderCustomEvent, -} from '../../engine'; +import { BaseCustomEvent, KeyValue, ModelRenderCustomEvent } from '../../engine'; import { ForAngularModule, getLogger } from 'src/lib/for-angular.module'; import { Logger } from '@decaf-ts/logging'; @@ -73,8 +70,7 @@ import { Logger } from '@decaf-ts/logging'; standalone: true, }) export class ComponentRendererComponent - implements OnInit, OnChanges, OnDestroy -{ + implements OnInit, OnChanges, OnDestroy { /** * @description Reference to the container where the dynamic component will be rendered. * @summary This ViewContainerRef provides the container where the dynamically created @@ -164,6 +160,12 @@ export class ComponentRendererComponent */ logger!: Logger; + @Input() + parent: any = undefined; + + + @ViewChild('inner', { read: TemplateRef, static: true }) + inner?: TemplateRef; /** * @description Creates an instance of ComponentRendererComponent. @@ -174,7 +176,7 @@ export class ComponentRendererComponent * @memberOf ComponentRendererComponent */ constructor() { - this.logger = getLogger(this); + this.logger = getLogger(this); } /** @@ -201,7 +203,9 @@ export class ComponentRendererComponent * @memberOf ComponentRendererComponent */ ngOnInit(): void { - this.createComponent(this.tag, this.globals); + if (!this.parent) + this.createComponent(this.tag, this.globals); + this.createParentComponent(); } /** @@ -274,7 +278,7 @@ export class ComponentRendererComponent for (let input of inputKeys) { if (!inputKeys.length) break; const prop = componentInputs.find( - (item: { propName: string }) => item.propName === input + (item: { propName: string }) => item.propName === input, ); if (!prop) { delete props[input]; @@ -291,7 +295,22 @@ export class ComponentRendererComponent metadata as ComponentMirror, this.vcr, this.injector as Injector, - [] + [], + ); + this.subscribeEvents(); + } + + createParentComponent() { + const { component, inputs } = this.parent; + const metadata = reflectComponentType(component) as ComponentMirror; + const template = this.vcr.createEmbeddedView(this.inner as TemplateRef, this.injector).rootNodes; + this.component = NgxRenderingEngine2.createComponent( + component, + inputs, + metadata, + this.vcr, + this.injector, + template, ); this.subscribeEvents(); } @@ -306,7 +325,8 @@ export class ComponentRendererComponent * @return {void} * @memberOf ComponentRendererComponent */ - ngOnChanges(changes: SimpleChanges): void {} + ngOnChanges(changes: SimpleChanges): void { + } /** * @description Subscribes to events emitted by the dynamic component. @@ -350,7 +370,7 @@ export class ComponentRendererComponent name: key, ...event, } as ModelRenderCustomEvent); - } + }, ); } } diff --git a/src/lib/components/crud-field/crud-field.component.html b/src/lib/components/crud-field/crud-field.component.html index c10c8180..921be54a 100644 --- a/src/lib/components/crud-field/crud-field.component.html +++ b/src/lib/components/crud-field/crud-field.component.html @@ -15,7 +15,7 @@ } @else { -
+
@if(type === 'textarea') { - @for(error of getErrors(); track error.key) { + @for(error of getErrors(container); track error.key) { * {{ sf(("errors." + error.message) | translate, this[error.key]) }} }
diff --git a/src/lib/components/crud-field/crud-field.component.spec.ts b/src/lib/components/crud-field/crud-field.component.spec.ts index 51090161..696e878b 100644 --- a/src/lib/components/crud-field/crud-field.component.spec.ts +++ b/src/lib/components/crud-field/crud-field.component.spec.ts @@ -1,14 +1,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CrudFieldComponent } from './crud-field.component'; -import { FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { IonicModule } from '@ionic/angular'; +import { FormControl, FormGroup } from '@angular/forms'; import { AngularFieldDefinition } from '../../engine'; import { TranslateFakeLoader, TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { ForAngularModule } from 'src/lib/for-angular.module'; -import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { OperationKeys } from '@decaf-ts/db-decorators'; import { NgxRenderingEngine2 } from 'src/lib/engine'; import { Model, ModelBuilderFunction } from '@decaf-ts/decorator-validation'; +import { NgxFormService } from '../../engine/NgxFormService'; const imports = [ ForAngularModule, @@ -16,12 +16,12 @@ const imports = [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateFakeLoader - } - }) + useClass: TranslateFakeLoader, + }, + }), ]; -describe('FieldComponent', () => { +describe('CrudFieldComponent', () => { let component: CrudFieldComponent; let fixture: ComponentFixture; // let formBuilder: FormBuilder; @@ -49,25 +49,29 @@ describe('FieldComponent', () => { component.name = 'test_field'; component.type = 'text'; component.operation = OperationKeys.CREATE; + component.formControl = new FormControl('value'); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); }); it('should create', () => { expect(component).toBeTruthy(); }); - const testCases: { type: string; selector: string }[] = [ - { type: 'textarea', selector: 'ion-textarea' }, - { type: 'checkbox', selector: 'ion-checkbox' }, - { type: 'radio', selector: 'ion-radio-group' }, - { type: 'select', selector: 'ion-select' }, - { type: 'text', selector: 'ion-input' }, - { type: 'number', selector: 'ion-input' }, - { type: 'email', selector: 'ion-input' }, - { type: 'password', selector: 'ion-input' }, - { type: 'date', selector: 'ion-input' }, + const testCases: { type: string; selector: string, value: any }[] = [ + { type: 'textarea', selector: 'ion-textarea', value: 'textarea value' }, + { type: 'checkbox', selector: 'ion-checkbox', value: 'checkbox value' }, + { type: 'radio', selector: 'ion-radio-group', value: 'checkbox value' }, + { type: 'select', selector: 'ion-select', value: 'select value' }, + { type: 'text', selector: 'ion-input', value: 'text value' }, + { type: 'number', selector: 'ion-input', value: 100 }, + { type: 'email', selector: 'ion-input', value: 'mail@mail.com' }, + { type: 'password', selector: 'ion-input', value: 'P@ssw0rd' }, + { type: 'date', selector: 'ion-input', value: '2025-01-01' }, ]; - testCases.forEach(({ type, selector }) => { + testCases.forEach(({ type, selector, value }) => { it(`should render ${type} when type is ${type}`, () => { const props: AngularFieldDefinition = { name: `test_${type}`, @@ -81,9 +85,13 @@ describe('FieldComponent', () => { ]; } + component.formControl = new FormControl(value); + component.formGroup = new FormGroup({ + [props.name]: component.formControl, + }); component.translatable = false; - Object.entries(props).forEach(([key, value]) => (component as any)[key] = value) + Object.entries(props).forEach(([key, value]) => (component as any)[key] = value); // component.props = props; fixture.detectChanges(); @@ -112,10 +120,14 @@ describe('FieldComponent', () => { // required: true, // } as AngularFieldDefinition; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); @@ -129,10 +141,13 @@ describe('FieldComponent', () => { component.value = 'abc'; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); @@ -146,10 +161,13 @@ describe('FieldComponent', () => { component.value = 'abcdef'; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); @@ -164,10 +182,13 @@ describe('FieldComponent', () => { component.value = '123'; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); @@ -176,16 +197,19 @@ describe('FieldComponent', () => { }); xit('should show error message when min value is not met', () => { - component.type = 'number'; + component.type = 'number'; component.label = 'Min Field'; component.min = 5; component.value = 3; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); @@ -194,16 +218,21 @@ describe('FieldComponent', () => { }); it('should show error message when max value is exceeded', () => { - component.type = 'number'; + component.type = 'number'; component.label = 'Min Field'; component.max = 5; component.value = 13; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); + fixture.detectChanges(); + fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); expect(component.formGroup.invalid).toBeTruthy(); @@ -214,10 +243,15 @@ describe('FieldComponent', () => { component.label = 'Valid Field'; component.value = 'Valid input'; fixture.detectChanges(); - if(!component.formGroup) - component.formGroup = new FormGroup({}); - component.formGroup.markAsTouched(); - component.formGroup.markAsDirty(); + const validators = NgxFormService['validatorsFromProps'](component); + component.formControl = new FormControl(component.value, validators); + component.formGroup = new FormGroup({ + [component.name]: component.formControl, + }); + component.formControl.markAsTouched(); + component.formControl.markAsDirty(); + fixture.detectChanges(); + fixture.detectChanges(); const errorDiv = fixture.nativeElement.querySelector('.error'); expect(errorDiv).toBeFalsy(); @@ -255,26 +289,26 @@ describe('FieldComponent', () => { component.readonly = true; fixture.detectChanges(); - if(!component.formGroup) + if (!component.formGroup) component.formGroup = new FormGroup({}); component.formGroup.markAsTouched(); component.formGroup.markAsDirty(); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('ion-input'); - expect(input.readonly).toBeTrue() + expect(input.readonly).toBeTrue(); }); it('should handle disabled attribute correctly', () => { component.label = 'Disabled Field'; component.value = ''; fixture.detectChanges(); - if(!component.formGroup) + if (!component.formGroup) component.formGroup = new FormGroup({}); component.formGroup.disable(); const input = fixture.nativeElement.querySelector('ion-input'); expect(component.formGroup.disabled).toBeTruthy(); - expect(input.disabled).toBeTrue() + expect(input.disabled).toBeTrue(); }); }); diff --git a/src/lib/components/crud-field/crud-field.component.ts b/src/lib/components/crud-field/crud-field.component.ts index 0493d3ca..ed64d08c 100644 --- a/src/lib/components/crud-field/crud-field.component.ts +++ b/src/lib/components/crud-field/crud-field.component.ts @@ -560,7 +560,7 @@ export class CrudFieldComponent extends NgxCrudFormField implements OnInit, OnDe * @memberOf CrudFieldComponent */ @Input() - override formGroup!: FormGroup | undefined; + override formGroup: FormGroup | undefined; @Input() override formControl!: FormControl; @@ -593,7 +593,7 @@ export class CrudFieldComponent extends NgxCrudFormField implements OnInit, OnDe this.formGroup = undefined; } else { if (this.type === HTML5InputTypes.RADIO && !this.value) - this.formGroup?.get(this.name)?.setValue(this.options[0].value); + this.formGroup?.get(this.name)?.setValue(this.options[0].value); // TODO: migrate to RenderingEngine } } diff --git a/src/lib/components/crud-form/crud-form.component.spec.ts b/src/lib/components/crud-form/crud-form.component.spec.ts index 86009600..84f2b16d 100644 --- a/src/lib/components/crud-form/crud-form.component.spec.ts +++ b/src/lib/components/crud-form/crud-form.component.spec.ts @@ -5,6 +5,7 @@ import { NgxRenderingEngine2 } from 'src/lib/engine'; import { Model, ModelBuilderFunction } from '@decaf-ts/decorator-validation'; import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { OperationKeys } from '@decaf-ts/db-decorators'; +import { FormGroup } from '@angular/forms'; const imports = [ ForAngularModule, @@ -17,7 +18,7 @@ const imports = [ }) ]; -describe('FormReactiveComponent', () => { +describe('CrudFormComponent', () => { let component: CrudFormComponent; let fixture: ComponentFixture; let engine; @@ -39,6 +40,7 @@ describe('FormReactiveComponent', () => { fixture = TestBed.createComponent(CrudFormComponent); component = fixture.componentInstance; component.operation = OperationKeys.CREATE; + component.formGroup = new FormGroup({}); fixture.detectChanges(); })); diff --git a/src/lib/components/crud-form/crud-form.component.ts b/src/lib/components/crud-form/crud-form.component.ts index 98a81441..44ebc5d7 100644 --- a/src/lib/components/crud-form/crud-form.component.ts +++ b/src/lib/components/crud-form/crud-form.component.ts @@ -14,26 +14,14 @@ import { Location } from '@angular/common'; import { FormGroup } from '@angular/forms'; import { FormElement } from '../../interfaces'; import { NgxFormService } from '../../engine/NgxFormService'; -import { - BaseCustomEvent, - CrudFormEvent, - Dynamic, - EventConstants, - FieldUpdateMode, - HTMLFormTarget, - RenderedModel, -} from '../../engine'; +import { CrudFormEvent, Dynamic, EventConstants, FieldUpdateMode, HTMLFormTarget, RenderedModel } from '../../engine'; import { CrudFormOptions } from './types'; -import { CrudOperations, InternalError, OperationKeys } from '@decaf-ts/db-decorators'; +import { CrudOperations, OperationKeys } from '@decaf-ts/db-decorators'; import { DefaultFormReactiveOptions } from './constants'; import { ForAngularModule, getLogger } from 'src/lib/for-angular.module'; import { IonIcon } from '@ionic/angular/standalone'; import { Model } from '@decaf-ts/decorator-validation'; import { Logger } from '@decaf-ts/logging'; -import { Repository } from '@decaf-ts/core'; -import { DecafRepository } from '../list/constants'; - - /** @@ -74,7 +62,7 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On * @type {Model| undefined} */ @Input() - model!: Model | undefined;; + model!: Model | undefined; @Input() updateOn: FieldUpdateMode = 'change'; @@ -98,7 +86,7 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On operation!: CrudOperations; @Input() - handlers!: Record any | Promise> + handlers!: Record any | Promise>; @Input() formGroup!: FormGroup | undefined; @@ -108,7 +96,7 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On * @summary Full dot-delimited path of the parent FormGroup. Set only when is part of a nested structure. * * @type {string} - * @memberOf CrudFieldComponent + * @memberOf CrudFormComponent */ @Input() childOf?: string; @@ -164,7 +152,7 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On } async ngOnInit() { - if(!this.logger) + if (!this.logger) this.logger = getLogger(this); if (this.operation === OperationKeys.READ || this.operation === OperationKeys.DELETE) this.formGroup = undefined; @@ -177,7 +165,7 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On } ngOnDestroy() { - if(this.formGroup) + if (this.formGroup) NgxFormService.unregister(this.formGroup); } @@ -193,11 +181,12 @@ export class CrudFormComponent implements OnInit, AfterViewInit, FormElement, On return false; const data = NgxFormService.getFormData(this.formGroup as FormGroup); + console.log('Submit=', data); this.submitEvent.emit({ data, - component:'FormReactiveComponent', + component: 'FormReactiveComponent', name: this.action || EventConstants.SUBMIT_EVENT, - handlers: this.handlers + handlers: this.handlers, }); } diff --git a/src/lib/components/fieldset/fieldset.component.html b/src/lib/components/fieldset/fieldset.component.html new file mode 100644 index 00000000..6fbdc8d9 --- /dev/null +++ b/src/lib/components/fieldset/fieldset.component.html @@ -0,0 +1,12 @@ +
+ + + + {{ name }} + +
+ +
+
+
+
diff --git a/src/lib/components/fieldset/fieldset.component.scss b/src/lib/components/fieldset/fieldset.component.scss new file mode 100644 index 00000000..478ce2d4 --- /dev/null +++ b/src/lib/components/fieldset/fieldset.component.scss @@ -0,0 +1,34 @@ +.dcf-fieldset { + margin-bottom: 1.8rem; + padding-bottom: 0; + padding-top: 1rem; + border: 1px solid #d1d1d1; + border-radius: 8px; + + ion-accordion { + &.accordion-collapsing, + &.accordion-collapsed { + margin-bottom: 1rem; + } + + ion-item[slot="header"] { + --border-color: transparent; + --border-radius: 6px; + --inner-border-width: 0; + --padding-start: 12px; + + legend { + font-weight: 600; + font-size: 1rem; + color: #333; + margin: 0; + } + } + + [slot="content"] { + padding-top: 1rem !important; + padding-inline: 0.75rem; + background-color: #fff; + } + } +} diff --git a/src/lib/components/fieldset/fieldset.component.spec.ts b/src/lib/components/fieldset/fieldset.component.spec.ts new file mode 100644 index 00000000..41743145 --- /dev/null +++ b/src/lib/components/fieldset/fieldset.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ForAngularModule } from 'src/lib/for-angular.module'; +import { FieldsetComponent } from './fieldset.component'; +import { NgxRenderingEngine2 } from 'src/lib/engine'; +import { Model, ModelBuilderFunction } from '@decaf-ts/decorator-validation'; +import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +const imports = [ + ForAngularModule, + FieldsetComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateFakeLoader, + }, + }), +]; + +describe('FieldsetComponent', () => { + let component: FieldsetComponent; + let fixture: ComponentFixture; + let engine; + + beforeAll(() => { + try { + engine = new NgxRenderingEngine2(); + Model.setBuilder(Model.fromModel as ModelBuilderFunction); + } catch (e: unknown) { + console.warn(`Engine already loaded`); + } + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports, + }).compileComponents(); + + fixture = TestBed.createComponent(FieldsetComponent); + component = fixture.componentInstance; + // component.operation = OperationKeys.CREATE; + fixture.detectChanges(); + })); + + it('should create', () => { + // If ngOnInit returns a promise, await it + // if (component?.ngOnInit instanceof Function) + // component.ngOnInit(); + + // If ngAfterViewInit returns a promise, await it + // if ((component as any)["ngAfterViewInit"] instanceof Function) + // component.ngAfterViewInit(); + + // Force change detection after async operations + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/lib/components/fieldset/fieldset.component.ts b/src/lib/components/fieldset/fieldset.component.ts new file mode 100644 index 00000000..6bbfa079 --- /dev/null +++ b/src/lib/components/fieldset/fieldset.component.ts @@ -0,0 +1,58 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Dynamic, HTMLFormTarget } from '../../engine'; +import { OperationKeys } from '@decaf-ts/db-decorators'; +import { ForAngularModule } from 'src/lib/for-angular.module'; +import { CollapsableDirective } from 'src/lib/directives/collapsable.directive'; +import { IonAccordion, IonAccordionGroup, IonItem } from '@ionic/angular/standalone'; + + +/** + * @component FieldsetComponent + * @example + * + * + * @param {string} name - Fieldset legend/title + * @param {string} target - The target + */ +@Dynamic() +@Component({ + standalone: true, + selector: 'ngx-decaf-fieldset', + templateUrl: './fieldset.component.html', + styleUrls: ['./fieldset.component.scss'], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [ForAngularModule, IonAccordionGroup, IonAccordion, IonItem, CollapsableDirective], +}) +export class FieldsetComponent implements OnInit { + @ViewChild('component', { static: false, read: ElementRef }) + component!: ElementRef; + + @Input() name: string = 'Child'; + @Input() target: HTMLFormTarget = '_self'; + + isOpen: boolean = false; + + ngOnInit() { + } + + // ngAfterViewInit() { + // // if (![OperationKeys.READ, OperationKeys.DELETE].includes(this.operation)) + // // NgxFormService.formAfterViewInit(this, this.rendererId); + // } + + // ngOnDestroy() { + // // if (this.formGroup) + // // NgxFormService.unregister(this.formGroup); + // } + + handleChange(event: CustomEvent) { + const { target, detail } = event; + const { value } = detail; + if ((target as HTMLIonAccordionGroupElement).tagName === 'ION-ACCORDION-GROUP') + this.isOpen = !!value; + } + + protected readonly OperationKeys = OperationKeys; +} diff --git a/src/lib/components/for-angular-components.module.ts b/src/lib/components/for-angular-components.module.ts index 07c10025..2748d596 100644 --- a/src/lib/components/for-angular-components.module.ts +++ b/src/lib/components/for-angular-components.module.ts @@ -9,7 +9,10 @@ import { ListItemComponent } from './list-item/list-item.component'; import { ComponentRendererComponent } from './component-renderer/component-renderer.component'; import { PaginationComponent } from './pagination/pagination.component'; import { ListComponent } from './list/list.component'; +import { FieldsetComponent } from './fieldset/fieldset.component'; +import { CollapsableDirective } from '../directives/collapsable.directive'; +const Directives = [CollapsableDirective]; const Components = [ ModelRendererComponent, ComponentRendererComponent, @@ -20,13 +23,14 @@ const Components = [ ListItemComponent, SearchbarComponent, PaginationComponent, - CrudFormComponent + CrudFormComponent, + FieldsetComponent, ]; @NgModule({ - imports: Components, + imports: [Components, Directives], declarations: [], schemas: [CUSTOM_ELEMENTS_SCHEMA], - exports: [Components, ForAngularModule], + exports: [Components, Directives, ForAngularModule], }) export class ForAngularComponentsModule {} diff --git a/src/lib/components/model-renderer/model-renderer.component.html b/src/lib/components/model-renderer/model-renderer.component.html index 6b285a9c..93d184f9 100644 --- a/src/lib/components/model-renderer/model-renderer.component.html +++ b/src/lib/components/model-renderer/model-renderer.component.html @@ -1,21 +1,20 @@ - -
@for (child of output?.children; track child) { - - + @if(child?.children?.length) { + + } @else { + + } }
- diff --git a/src/lib/components/model-renderer/model-renderer.component.ts b/src/lib/components/model-renderer/model-renderer.component.ts index f224308d..8a3dc00d 100644 --- a/src/lib/components/model-renderer/model-renderer.component.ts +++ b/src/lib/components/model-renderer/model-renderer.component.ts @@ -24,10 +24,11 @@ import { import { KeyValue, ModelRenderCustomEvent } from 'src/lib/engine/types'; import { ForAngularModule } from 'src/lib/for-angular.module'; import { Renderable } from '@decaf-ts/ui-decorators'; +import { ComponentRendererComponent } from '../component-renderer/component-renderer.component'; @Component({ standalone: true, - imports: [ForAngularModule, NgComponentOutlet], + imports: [ForAngularModule, NgComponentOutlet, ComponentRendererComponent], selector: 'ngx-decaf-model-renderer', templateUrl: './model-renderer.component.html', styleUrl: './model-renderer.component.scss', @@ -98,7 +99,6 @@ export class ModelRendererComponent this.output = undefined; } - private subscribeEvents(): void { if (this.instance) { const self = this; diff --git a/src/lib/directives/collapsable.directive.ts b/src/lib/directives/collapsable.directive.ts new file mode 100644 index 00000000..83437d54 --- /dev/null +++ b/src/lib/directives/collapsable.directive.ts @@ -0,0 +1,22 @@ +import { AfterContentInit, Directive, ElementRef } from '@angular/core'; + + +@Directive({ + selector: '[decafCollapsable]', + standalone: true +}) +export class CollapsableDirective implements AfterContentInit{ + + constructor(private element?: ElementRef) {} + + ngAfterContentInit() { + const element = this.element?.nativeElement; + if(element) { + const requiredFields = element.querySelectorAll('[required]') as NodeListOf; + if(requiredFields.length) { + const accordion = element?.closest('ion-accordion-group') as HTMLElement; + accordion.setAttribute('value', 'open'); + } + } + } +} diff --git a/src/lib/directives/decaf-field.directive.spec.ts b/src/lib/directives/decaf-field.directive.spec.ts deleted file mode 100644 index 834f4666..00000000 --- a/src/lib/directives/decaf-field.directive.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DecafFieldDirective } from './decaf-field.directive'; - -describe('FormElementNameDirective', () => { - it('should create an instance', () => { - const directive = new DecafFieldDirective(); - expect(directive).toBeTruthy(); - }); -}); diff --git a/src/lib/directives/decaf-field.directive.ts b/src/lib/directives/decaf-field.directive.ts deleted file mode 100644 index c4827fa5..00000000 --- a/src/lib/directives/decaf-field.directive.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Directive, HostBinding, Input } from '@angular/core'; - -@Directive({ - selector: '[appDecafField]', - standalone: true, -}) -export class DecafFieldDirective { - @Input({ alias: 'appDecafField' }) fieldName!: string; - - @HostBinding('#name') name!: string; - - constructor() { - this.name = this.fieldName; - } -} diff --git a/src/lib/engine/NgxCrudFormField.ts b/src/lib/engine/NgxCrudFormField.ts index d069b3a2..f15bb325 100644 --- a/src/lib/engine/NgxCrudFormField.ts +++ b/src/lib/engine/NgxCrudFormField.ts @@ -173,7 +173,10 @@ export abstract class NgxCrudFormField implements ControlValueAccessor, FieldPro * @description Retrieves all errors associated with the field * @returns {Array<{key: string, message: string}>} An array of error objects */ - getErrors(): Array<{ key: string; message: string; }> { + getErrors(parent: HTMLElement): Array<{ key: string; message: string; }> { + const collapsableContainer = parent.closest('ion-accordion-group'); + if(collapsableContainer) + collapsableContainer.setAttribute('value', 'open'); return Object.keys(this.formControl.errors ?? {}).map(key => ({ key: key, message: key, diff --git a/src/lib/engine/NgxFormService.ts b/src/lib/engine/NgxFormService.ts index 5c63dac0..dddd5d5c 100644 --- a/src/lib/engine/NgxFormService.ts +++ b/src/lib/engine/NgxFormService.ts @@ -14,6 +14,7 @@ export interface ComponentConfig { component: string; inputs: ComponentInput; injector: any; + children?: ComponentConfig[]; } /** @@ -31,10 +32,37 @@ export class NgxFormService { * A static object that stores form controls props. */ private static controls = new WeakMap(); + private static formRegistry = new Map(); private constructor() { } + /** + * Registers a FormGroup in the registry with the given identifier. + * + * Throws an error if the identifier already exists in the registry. + * + * @param {string} formId - Unique identifier for the form. + * @param {FormGroup} formGroup - The FormGroup instance to register. + * @throws {Error} If a FormGroup with the given id already exists. + */ + static addRegistry(formId: string, formGroup: FormGroup): void { + if (this.formRegistry.has(formId)) + throw new Error(`A FormGroup with id '${formId}' is already registered.`); + this.formRegistry.set(formId, formGroup); + } + + + /** + * Removes a FormGroup from the registry by its identifier. + * If the id does not exist, the operation is silently ignored. + * + * @param {string} formId - The identifier of the form to remove. + */ + static removeRegistry(formId: string): void { + this.formRegistry.delete(formId); + } + /** * Resolves the parent FormGroup and control name from a dot-delimited path. * Automatically creates missing intermediate FormGroups if necessary. @@ -63,40 +91,92 @@ export class NgxFormService { * Adds a FormControl to the specified FormGroup, respecting the nesting structure * defined via `childOf`. Also updates the component's `formGroup` reference. * - * @param {} component - The component configuration to process * @param {FormGroup} formGroup - The root FormGroup to add the control to + * @param {ComponentInput} componentProps - The component configuration to process */ - private static addFormControl(component: ComponentConfig, formGroup: FormGroup): void { - const { name, childOf } = component.inputs; + private static addFormControl(formGroup: FormGroup, componentProps: FieldProperties & ComponentInput): void { + const { name, childOf } = componentProps; const fullPath = childOf ? `${childOf}.${name}` : name; const [parentGroup, controlName] = this.resolveParentGroup(formGroup, fullPath); if (!parentGroup.get(controlName)) { const control = NgxFormService.fromProps( - component.inputs, - component.inputs.updateMode || 'change', + componentProps, + componentProps.updateMode || 'change', ); - NgxFormService.register(control, component.inputs); + NgxFormService.register(control, componentProps); parentGroup.addControl(controlName, control); } - component.inputs.formGroup = parentGroup; - component.inputs.formControl = parentGroup.get(controlName) as FormControl; + componentProps['formGroup'] = parentGroup; + componentProps['formControl'] = parentGroup.get(controlName) as FormControl; + } + + /** + * Retrieves a control (FormGroup, FormControl, or FormArray) from a form registered by its ID. + * If a path is provided, it returns the nested control at that path; + * otherwise, it returns the root FormGroup. + * + * @param {string} formId - The form registry identifier. + * @param {string} [path] - Optional dot-delimited path to the control (e.g., 'address.street'). + * @returns {AbstractControl} The requested control. + * @throws {Error} If the control at the specified path does not exist. + */ + static getControlFromForm(formId: string, path?: string): AbstractControl { + const form = this.formRegistry.get(formId); + if (!form) + throw new Error(`Form with id '${formId}' not found in the registry.`); + + if (!path) + return form; + + const control = form.get(path); + if (!control) + throw new Error(`Control with path '${path}' not found in form '${formId}'.`); + return control; } /** * Builds a FormGroup from a flat array of components, using the `childOf` property * to establish nested hierarchy. * - * @param components - Flat array of component configurations + * @param {string} id - form identifier + * @param {[ComponentConfig]} components - Flat array of component configurations + * @param {boolean} registry - Whether to register the generated FormGroup * @returns {FormGroup} FormGroup - Root FormGroup containing the complete nested structure */ - static createFormFromComponents(components: ComponentConfig[]): FormGroup { - const rootForm = new FormGroup({}); + static createFormFromComponents(id: string, components: ComponentConfig[], registry: boolean = false): FormGroup { + const form = new FormGroup({}); components.forEach(component => { - this.addFormControl(component, rootForm); + this.addFormControl(form, component.inputs); }); - return rootForm; + + if (registry) + this.addRegistry(id, form); + + return form; + } + + /** + * Add a control or formGroup from the registry and + * adds a FormControl based on the given component configuration. + * + * If the form does not exist in the registry for the given `renderId`, + * it initializes a new FormGroup and registers it. + * + * @param {string} id - Unique identifier for the form instance. + * @param {ComponentConfig} componentProperties - Component configuration containing control metadata. + * @returns {AbstractControl} The updated root FormGroup. + */ + static addControlFromProps(id: string, componentProperties: FieldProperties): AbstractControl { + const form = this.formRegistry.get(id) ?? new FormGroup({}); + if (!this.formRegistry.has(id)) + this.addRegistry(id, form); + + if (componentProperties.path) // if a path exists, it is a field + this.addFormControl(form, componentProperties); + + return form; } /** diff --git a/src/lib/engine/NgxRenderingEngine2.ts b/src/lib/engine/NgxRenderingEngine2.ts index 1a12b7c9..d2c1a195 100644 --- a/src/lib/engine/NgxRenderingEngine2.ts +++ b/src/lib/engine/NgxRenderingEngine2.ts @@ -100,6 +100,7 @@ export class NgxRenderingEngine2 extends RenderingEngine} tpl - The template reference for content projection + * @param {string} registryFormId - Form identifier for the component renderer * @return {AngularDynamicOutput} The Angular component output with component reference and inputs * * @mermaid @@ -128,20 +129,22 @@ export class NgxRenderingEngine2 extends RenderingEngine, + registryFormId: string = Date.now().toString(36).toUpperCase(), ): AngularDynamicOutput { const cmp = (fieldDef as any)?.component || NgxRenderingEngine2.components(fieldDef.tag); const component = (cmp.constructor) as unknown as Type; const componentMetadata = reflectComponentType(component); - if (!componentMetadata) + if (!componentMetadata) { throw new InternalError(`Metadata for component ${fieldDef.tag} not found.`); + } const { inputs: possibleInputs } = componentMetadata; - const inputs = fieldDef.props; - const unmappedKeys = Object.keys(inputs).filter((input) => { + const inputs = { ...fieldDef.props }; + + const unmappedKeys = Object.keys(inputs).filter(input => { const isMapped = possibleInputs.find(({ propName }) => propName === input); - if (!isMapped) - delete inputs[input]; + if (!isMapped) delete inputs[input]; return !isMapped; }); @@ -149,48 +152,41 @@ export class NgxRenderingEngine2 extends RenderingEngine)['rendererId'] = fieldDef.rendererId; - if (fieldDef.children && fieldDef.children.length) { - const self = this; - - const processChild = (child: FieldDefinition): unknown => { - if (!child?.tag) - return child; - - const childResult = self.fromFieldDefinition(child, vcr, injector, tpl); - if (childResult?.children && childResult.children.length) - return (childResult.children || []).map(processChild as any); - - // if (child?.children && child.children.length) - // return child.children.map(processChild); + // process children + if (fieldDef.children?.length) { + result.children = fieldDef.children.map((child) => { + // create a child form and add its controls as properties of child.props + NgxFormService.addControlFromProps(registryFormId, child.props); + return this.fromFieldDefinition(child, vcr, injector, tpl, registryFormId); + }); + } - return childResult; - }; + // generating DOM + vcr.clear(); + const template = vcr.createEmbeddedView(tpl, injector).rootNodes; + const componentInstance = NgxRenderingEngine2.createComponent( + component, + { ...inputs, model: this._model }, + componentMetadata, + vcr, + injector, + template, + ); - result.children = (fieldDef.children.map(processChild) as any).flat() as AngularDynamicOutput[]; - vcr.clear(); - const template = vcr.createEmbeddedView(tpl, injector).rootNodes; - const componentInstance = NgxRenderingEngine2.createComponent( - component, - { ...inputs, ...{ model: this._model } }, - componentMetadata, - vcr, - injector, - template, - ); - result.instance = NgxRenderingEngine2._instance = componentInstance.instance as Type; - } + result.instance = NgxRenderingEngine2._instance = componentInstance.instance as Type; return result; } + /** * @description Creates an Angular component instance * @summary This static utility method creates an Angular component instance with the specified @@ -282,16 +278,18 @@ export class NgxRenderingEngine2 extends RenderingEngine)['formGroup'] = NgxFormService.getControlFromForm(formId); + NgxFormService.removeRegistry(formId); } catch (e: unknown) { throw new InternalError( `Failed to render Model ${model.constructor.name}: ${e}`, ); } - // set root for formGroup of crud-form-component. TODO: Move to fromFieldDefinition - (result!.instance! as any)['formGroup'] = NgxFormService.createFormFromComponents((result.children || []) as any[]); return result; } diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index 25a92cd8..a7a281e2 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -1,8 +1,7 @@ import { IonCheckbox, IonInput, IonSelect, IonTextarea } from '@ionic/angular'; import { TextFieldTypes } from '@ionic/core'; import { Injector, Type } from '@angular/core'; -import { FormGroup } from '@angular/forms'; -import { EventHandler } from '@decaf-ts/ui-decorators'; +import { FormControl, FormGroup } from '@angular/forms'; export type KeyValue = Record; @@ -93,6 +92,8 @@ export interface ComponentMetadata { * @property {Node[][]} [content] - Optional content nodes for projection * @property {AngularDynamicOutput[]} [children] - Optional child components * @property {Type} [instance] - Optional component instance + * @property {FormGroup} [formGroup] - Optional component FormGroup + * @property {FormControl} [formControl] - Optional component FormControl * @memberOf module:engine */ export type AngularDynamicOutput = { @@ -103,6 +104,8 @@ export type AngularDynamicOutput = { content?: Node[][]; children?: AngularDynamicOutput[]; instance?: Type; + formGroup?: FormGroup; + formControl?: FormControl; }; /** diff --git a/src/theme/base.scss b/src/theme/base.scss index 37a8a907..a24c6d80 100644 --- a/src/theme/base.scss +++ b/src/theme/base.scss @@ -14,6 +14,11 @@ border: 2px solid red; } +.dcf-fieldset { + .dcf-fieldset{ + margin-top: 2.25rem !important; + } +} // ============================================================================================== /** Base =============================================== */ diff --git a/workdocs/reports/jest.coverage.config.ts b/workdocs/reports/jest.coverage.config.ts deleted file mode 100644 index a617e4dd..00000000 --- a/workdocs/reports/jest.coverage.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Config } from "@jest/types"; -import conf from "../../jest.config"; - -const config: Config.InitialOptions = { - ...conf, - collectCoverage: true, - coverageDirectory: "./workdocs/reports/coverage", - reporters: [ - "default", - [ - "jest-junit", - { - outputDirectory: "./workdocs/reports/junit", - outputName: "junit-report.xml", - }, - ], - [ - "jest-html-reporters", - { - publicPath: "./workdocs/reports/html", - filename: "test-report.html", - openReport: true, - expand: true, - pageTitle: "ts-workspace Test Report", - stripSkippedTest: true, - darkTheme: true, - enableMergeData: true, - dataMergeLevel: 2, - }, - ], - ], - coverageThreshold: { - global: { - branches: 70, - functions: 100, - lines: 80, - statements: 90, - }, - }, -}; - -export default config;